diff --git a/.github/workflows/LANCommander.Launcher.yml b/.github/workflows/LANCommander.Launcher.yml index 7094c282..a5764641 100644 --- a/.github/workflows/LANCommander.Launcher.yml +++ b/.github/workflows/LANCommander.Launcher.yml @@ -159,7 +159,145 @@ 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 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 = @{ diff --git a/.github/workflows/LANCommander.Nightly.yml b/.github/workflows/LANCommander.Nightly.yml index 4082d819..f3de67d9 100644 --- a/.github/workflows/LANCommander.Nightly.yml +++ b/.github/workflows/LANCommander.Nightly.yml @@ -444,6 +444,18 @@ jobs: name: LANCommander.Launcher-Windows-x64-v${{ needs.prep.outputs.version_tag }}.zip path: artifacts + - name: Download Launcher Linux ARM64 AppImage + uses: actions/download-artifact@v4 + with: + name: LANCommander.Launcher-Linux-arm64-v${{ needs.prep.outputs.version_tag }}.AppImage + path: artifacts + + - name: Download Launcher Linux x64 AppImage + uses: actions/download-artifact@v4 + with: + name: LANCommander.Launcher-Linux-x64-v${{ needs.prep.outputs.version_tag }}.AppImage + path: artifacts + - name: Create or update nightly release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/LANCommander.PR.yml b/.github/workflows/LANCommander.PR.yml index dcdeb1fe..72c8157d 100644 --- a/.github/workflows/LANCommander.PR.yml +++ b/.github/workflows/LANCommander.PR.yml @@ -12,6 +12,8 @@ on: permissions: contents: write packages: read + checks: write + pull-requests: write jobs: prep: @@ -56,6 +58,64 @@ jobs: 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 diff --git a/.github/workflows/LANCommander.Release.yml b/.github/workflows/LANCommander.Release.yml index 6ffca98e..8b8d3ba2 100644 --- a/.github/workflows/LANCommander.Release.yml +++ b/.github/workflows/LANCommander.Release.yml @@ -305,6 +305,18 @@ jobs: name: LANCommander.Launcher-Windows-x64-v${{ needs.prep.outputs.version_tag }}.zip path: artifacts + - name: Download Launcher Linux ARM64 AppImage + uses: actions/download-artifact@v4 + with: + name: LANCommander.Launcher-Linux-arm64-v${{ needs.prep.outputs.version_tag }}.AppImage + path: artifacts + + - name: Download Launcher Linux x64 AppImage + uses: actions/download-artifact@v4 + with: + name: LANCommander.Launcher-Linux-x64-v${{ needs.prep.outputs.version_tag }}.AppImage + path: artifacts + - name: Download Packager Windows x86 uses: actions/download-artifact@v4 with: @@ -336,6 +348,8 @@ jobs: 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 - name: Checkout Repo for Docker build diff --git a/.github/workflows/LANCommander.Server.yml b/.github/workflows/LANCommander.Server.yml index f0001d70..19755307 100644 --- a/.github/workflows/LANCommander.Server.yml +++ b/.github/workflows/LANCommander.Server.yml @@ -162,7 +162,69 @@ 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/Directory.Packages.props b/Directory.Packages.props index 1d5d2951..085b603b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -13,7 +13,7 @@ - + @@ -69,7 +69,9 @@ + + @@ -97,7 +99,7 @@ - + diff --git a/LANCommander.Documentation/Releases/2.1.0.mdx b/LANCommander.Documentation/Releases/2.1.0.mdx index 401b8684..2e80e1e7 100644 --- a/LANCommander.Documentation/Releases/2.1.0.mdx +++ b/LANCommander.Documentation/Releases/2.1.0.mdx @@ -9,7 +9,7 @@ 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.2** — see [Patch Updates](#patch-updates) below for what's changed since the initial release. +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. @@ -368,10 +368,297 @@ When a game has no update available, the launcher will now refresh the manifest +### 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 @@ -388,4 +675,4 @@ When a game has no update available, the launcher will now refresh the manifest ## Contributors - + diff --git a/LANCommander.Documentation/Releases/2.1.3.mdx b/LANCommander.Documentation/Releases/2.1.3.mdx new file mode 100644 index 00000000..85c0bb7b --- /dev/null +++ b/LANCommander.Documentation/Releases/2.1.3.mdx @@ -0,0 +1,67 @@ +--- +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 new file mode 100644 index 00000000..5a8f0d97 --- /dev/null +++ b/LANCommander.Documentation/Releases/2.1.4.mdx @@ -0,0 +1,61 @@ +--- +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 new file mode 100644 index 00000000..e362205e --- /dev/null +++ b/LANCommander.Documentation/Releases/2.1.5.mdx @@ -0,0 +1,41 @@ +--- +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 new file mode 100644 index 00000000..cb574a04 --- /dev/null +++ b/LANCommander.Documentation/Releases/2.1.6.mdx @@ -0,0 +1,49 @@ +--- +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 new file mode 100644 index 00000000..a1a2918f --- /dev/null +++ b/LANCommander.Documentation/Releases/2.1.7.mdx @@ -0,0 +1,35 @@ +--- +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 new file mode 100644 index 00000000..0f58aaff --- /dev/null +++ b/LANCommander.Documentation/Releases/2.1.8.mdx @@ -0,0 +1,37 @@ +--- +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 new file mode 100644 index 00000000..cf305a44 --- /dev/null +++ b/LANCommander.Documentation/Releases/2.1.9.mdx @@ -0,0 +1,32 @@ +--- +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/Server/Archives.md b/LANCommander.Documentation/Server/Archives.md new file mode 100644 index 00000000..06ffe376 --- /dev/null +++ b/LANCommander.Documentation/Server/Archives.md @@ -0,0 +1,51 @@ +--- +title: Archives +--- + +# Archives + +Games, redistributables, and tools are distributed to clients as ZIP archives. When a launcher installs a game it **streams the archive straight from the server and extracts it on the fly**. This keeps installs fast and avoids needing double the disk space, but it means the archive has to be readable from start to finish without seeking backwards. + +Most ZIP files satisfy this without any special effort. A small number of archives, however, are written in a layout that a streaming reader cannot extract reliably. This page explains how to create streaming-safe archives and how to fix existing ones. + +## Why some archives fail to install + +A streaming reader discovers each file's boundaries as the bytes arrive. For that to work, every entry must declare its size up front, in its **local file header**. + +Some archiving tools instead write entries in a *streaming* layout, where the size is unknown when the entry starts and is recorded afterwards in a trailing **data descriptor**. For compressed entries this is fine as the compressed bytes cannot be mistaken for the data descriptor. However, if you store an uncompressed entry that _happens to be another archive_, this can confuse the streaming reader and cause it to improperly determine the size of the entry being extracted. + +:::info +The problematic combination is specifically **stored (uncompressed) + streaming data descriptor**. A stored entry whose size *is* in its local header installs fine at any size, and compressed entries are unaffected. +::: + +## Creating streaming-safe archives + +The safest rule of thumb: **create archives with a tool writing to a file** (not piping to a stream), and let large already-compressed payloads be stored with their sizes recorded normally. Writing to a real file lets the tool go back and fill in each entry's size in its local header instead of using a streaming data descriptor. + +| Tool | Recommendation | +|------|----------------| +| **7-Zip** (GUI or `7z`) | Safe by defaul. Use a normal `Add to archive` / `7z a archive.zip files` and write to a _local disk_, not a network share. | +| **Windows Explorer** (Send to → Compressed folder) | Safe by default. | +| **Info-ZIP `zip`** | Safe when writing to a file (`zip -r archive.zip folder`). Avoid piping to stdout (`zip - ...`), which forces streaming data descriptors. | +| **PowerShell `Compress-Archive`** | Safe by default, do not write to a network share. | + +For payloads larger than 4 GiB, make sure the tool produces a **ZIP64** archive (all the tools above do this automatically when needed). + +:::info +Future versions of the launcher will have a built-in packaging tool to help in the creation of archives. +::: + +## Checking and repacking existing archives + +The server can detect and fix archives that use the problematic layout. On a game's **Archives** tab, each uploaded archive has a **Check Streaming Compatibility** action (the shield icon). + +1. Click **Check Streaming Compatibility** on an archive. +2. If the archive is safe, you'll get a confirmation message and nothing else happens. +3. If it contains stored entries written with a streaming data descriptor, you'll be prompted to **repack** it. +4. Choosing to repack queues a background job that rewrites the archive into a streaming-safe layout. Compression is preserved per entry so the repack does not waste time re-compressing already-compressed data. The job runs in the background and can take a while for multi-gigabyte archives. + +Repacking rewrites the file in place once complete and recalculates its reported sizes. File contents (and their CRCs) are unchanged; only the archive's internal layout is corrected. + +:::warning +Repacking reads and rewrites the entire archive, so it temporarily needs free disk space roughly equal to the archive's size in the same storage location. +::: \ No newline at end of file diff --git a/LANCommander.Documentation/Server/Settings/Authentication/External Providers/Authentik.md b/LANCommander.Documentation/Server/Settings/Authentication/External Providers/Authentik.md index 05b502b0..5c8abbfa 100644 --- a/LANCommander.Documentation/Server/Settings/Authentication/External Providers/Authentik.md +++ b/LANCommander.Documentation/Server/Settings/Authentication/External Providers/Authentik.md @@ -13,6 +13,13 @@ Scopes: - email ``` +:::tip +Because Authentik exposes an OpenID Connect configuration URL, you can use the **Discover** +button on the provider to map the standard claims and add the base scopes automatically. +See the [Authentication overview](/Server/Settings/Authentication/Overview#discovery-oidc) +for details. +::: + Make sure to set your redirect URLs appropriately. LANCommander expects the following redirect URL scheme: ```http(s):///SignInOIDC``` diff --git a/LANCommander.Documentation/Server/Settings/Authentication/Overview.md b/LANCommander.Documentation/Server/Settings/Authentication/Overview.md new file mode 100644 index 00000000..06b34caf --- /dev/null +++ b/LANCommander.Documentation/Server/Settings/Authentication/Overview.md @@ -0,0 +1,132 @@ +--- +title: Authentication +sidebar_label: Overview +sidebar_position: 1 +--- + +# Authentication + +LANCommander can delegate sign-in to external identity providers in addition to its +built-in local accounts. Two provider protocols are supported: + +- **OpenID Connect (OIDC)** — recommended. The provider exposes a discovery document + (the *well-known configuration URL*) that LANCommander uses to resolve all of its + endpoints automatically. +- **OAuth2** — for providers that do not offer OIDC discovery. You supply each endpoint + (authorization, token, user info) by hand. + +:::info +SAML is listed in the provider type list but is **not implemented**. Selecting it will +prevent the provider from being registered. +::: + +Providers are configured under **Settings → Authentication → External Providers**. A +**server restart is required** for changes to authentication providers to take effect. + +## Configuring a provider + +Each provider shares a common set of fields, plus a few that depend on the type. + +| Field | Applies to | Description | +| --- | --- | --- | +| Name | All | Display name shown on the login button. | +| Color / Icon | All | Styling for the login button. | +| Type | All | `OAuth2` or `OpenIdConnect`. | +| Client ID / Client Secret | All | Credentials issued by the provider. | +| Configuration Endpoint | OIDC | The provider's `.well-known/openid-configuration` URL. | +| Authorization / Token / User Info Endpoint | OAuth2 | The provider's individual endpoints. | +| Scopes | All | Scopes requested during sign-in (see below). | +| Claim Mappings | All | How provider claims map onto LANCommander users (see below). | + +### Redirect URLs + +When registering LANCommander with your provider, configure the redirect (callback) URL +to match the protocol: + +| Type | Redirect URL | +| --- | --- | +| OpenID Connect | `http(s):///SignInOIDC` | +| OAuth2 | `http(s):///SignInOAuth` | + +:::info +If you see `Correlation failed.` errors in the logs, review your +[cookie policy settings](/Server/Settings/Authentication/Security). +::: + +## Scopes + +Scopes determine which information the provider releases during sign-in. At minimum an +OIDC provider needs `openid`; `profile` and `email` are commonly added so the user's +name and email claims are returned. Some providers expose a `roles` or `groups` scope +for [role synchronization](#role-synchronization). + +## Claim mappings + +A **claim mapping** projects a claim returned by the provider onto a destination claim +that LANCommander understands and applies to the user on login. + +- **Claim** (the source) is a key in the provider's user-info response, e.g. + `preferred_username`. +- **Destination** (the target) is one of the well-known names below. + +For OIDC providers the configured claim mappings run over the user-info endpoint +response, so make sure the scopes you request actually cause those claims to be returned. + +### Recognized destinations + +| Destination | Maps to | Notes | +| --- | --- | --- | +| `nameidentifier` | External unique ID | **Required** — links the provider login to a LANCommander account. | +| `name` | Username | | +| `email` | Email address | | +| `alias` | Display alias | | +| `role` (or `roles`) | Role name(s) | Array values are expanded into multiple roles; nested keys are supported with dotted paths (e.g. `realm_access.roles`). Each value is used directly as a role name. | + +The full `http://schemas.xmlsoap.org/...` claim URIs are also accepted for `name`, +`email`, and `nameidentifier`. When no username claim is available (or it collides with +an existing local account), the user is sent to manual registration to finish linking. + +## Discovery (OIDC) + +For OpenID Connect providers, the **Discover** button next to the claim mappings reads +the provider's discovery document and configures the provider for you: + +- **Standard claims are mapped automatically.** When the provider advertises them, the + following are mapped: + + | Destination | Source claim (first advertised wins) | + | --- | --- | + | `nameidentifier` | `sub` | + | `email` | `email` | + | `name` | `preferred_username` → `name` → `username` | + | `alias` | `nickname` → `name` | + | `role` | `roles` → `groups` | + +- **Base scopes are added automatically.** `openid` is always added (it is required for + the OIDC flow); `profile`, `email`, `roles`, and `groups` are added when the provider + advertises them. +- Any other advertised claims appear as clickable suggestions you can add as mappings, + and as autocomplete options while editing a mapping. + +Discovery never overwrites mappings or scopes you have already configured, and re-running +it adds nothing new. + +:::info +The discovery document's `claims_supported` and `scopes_supported` lists are **advisory**. +They are optional in the OIDC spec and many providers under-report them, so treat the +results as suggestions — you can always add claims and scopes manually. +::: + +## Role synchronization + +When a provider login supplies role claims (mapped to `role`), LANCommander syncs the +user's roles on every login: + +- Roles named in the claims that don't yet exist are created automatically. +- Roles the user no longer has in the claims are removed — **except** the Administrator + role and the configured default role, which are never removed automatically. + +## Provider examples + +See the [External Providers](/Server/Settings/Authentication/External%20Providers/Authentik) +section for ready-to-use configuration examples. diff --git a/LANCommander.Launcher.Data/DatabaseContext.cs b/LANCommander.Launcher.Data/DatabaseContext.cs index 952f7487..4ec0a9c4 100644 --- a/LANCommander.Launcher.Data/DatabaseContext.cs +++ b/LANCommander.Launcher.Data/DatabaseContext.cs @@ -139,6 +139,14 @@ namespace LANCommander.Launcher.Data gr => gr.HasOne().WithMany().HasForeignKey("GameId") ); + builder.Entity() + .HasMany(g => g.Tools) + .WithMany(t => t.Games) + .UsingEntity( + gt => gt.HasOne(x => x.Tool).WithMany(t => t.GameTools).HasForeignKey(x => x.ToolId).OnDelete(DeleteBehavior.Cascade), + gt => gt.HasOne(x => x.Game).WithMany(g => g.GameTools).HasForeignKey(x => x.GameId).OnDelete(DeleteBehavior.Cascade), + gt => gt.HasKey(x => new { x.GameId, x.ToolId })); + builder.Entity() .HasMany(g => g.DependentGames) .WithOne(g => g.BaseGame) diff --git a/LANCommander.Launcher.Data/Migrations/20260627164452_AddPerGameToolInstallState.Designer.cs b/LANCommander.Launcher.Data/Migrations/20260627164452_AddPerGameToolInstallState.Designer.cs new file mode 100644 index 00000000..104911a6 --- /dev/null +++ b/LANCommander.Launcher.Data/Migrations/20260627164452_AddPerGameToolInstallState.Designer.cs @@ -0,0 +1,985 @@ +// +using System; +using LANCommander.Launcher.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LANCommander.Launcher.Data.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260627164452_AddPerGameToolInstallState")] + partial class AddPerGameToolInstallState + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.9"); + + modelBuilder.Entity("CategoryGame", b => + { + b.Property("CategoriesId") + .HasColumnType("TEXT"); + + b.Property("GamesId") + .HasColumnType("TEXT"); + + b.HasKey("CategoriesId", "GamesId"); + + b.HasIndex("GamesId"); + + b.ToTable("CategoryGame"); + }); + + modelBuilder.Entity("CollectionGame", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "GameId"); + + b.HasIndex("GameId"); + + b.ToTable("CollectionGame"); + }); + + modelBuilder.Entity("GameDeveloper", b => + { + b.Property("DeveloperId") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.HasKey("DeveloperId", "GameId"); + + b.HasIndex("GameId"); + + b.ToTable("GameDeveloper"); + }); + + modelBuilder.Entity("GameGenre", b => + { + b.Property("GamesId") + .HasColumnType("TEXT"); + + b.Property("GenresId") + .HasColumnType("TEXT"); + + b.HasKey("GamesId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("GameGenre"); + }); + + modelBuilder.Entity("GamePlatform", b => + { + b.Property("GamesId") + .HasColumnType("TEXT"); + + b.Property("PlatformsId") + .HasColumnType("TEXT"); + + b.HasKey("GamesId", "PlatformsId"); + + b.HasIndex("PlatformsId"); + + b.ToTable("GamePlatform"); + }); + + modelBuilder.Entity("GamePublisher", b => + { + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("PublisherId") + .HasColumnType("TEXT"); + + b.HasKey("GameId", "PublisherId"); + + b.HasIndex("PublisherId"); + + b.ToTable("GamePublisher"); + }); + + modelBuilder.Entity("GameRedistributable", b => + { + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("RedistributableId") + .HasColumnType("TEXT"); + + b.HasKey("GameId", "RedistributableId"); + + b.HasIndex("RedistributableId"); + + b.ToTable("GameRedistributable"); + }); + + modelBuilder.Entity("GameTag", b => + { + b.Property("GamesId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("GamesId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("GameTag"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Collections"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Company", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Companies"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Engine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Engines"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BaseGameId") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("EngineId") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("InstallDirectory") + .HasColumnType("TEXT"); + + b.Property("Installed") + .HasColumnType("INTEGER"); + + b.Property("InstalledOn") + .HasColumnType("TEXT"); + + b.Property("InstalledVersion") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("ReleasedOn") + .HasColumnType("TEXT"); + + b.Property("Singleplayer") + .HasColumnType("INTEGER"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BaseGameId"); + + b.HasIndex("EngineId"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.GameExternalId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("GameExternalIds"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.GameTool", b => + { + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("ToolId") + .HasColumnType("TEXT"); + + b.Property("InstallDirectory") + .HasColumnType("TEXT"); + + b.Property("Installed") + .HasColumnType("INTEGER"); + + b.Property("InstalledOn") + .HasColumnType("TEXT"); + + b.Property("InstalledVersion") + .HasColumnType("TEXT"); + + b.HasKey("GameId", "ToolId"); + + b.HasIndex("ToolId"); + + b.ToTable("GameTool"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Libraries"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Media", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Crc32") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("FileId") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Media"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.MultiplayerMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("MinPlayers") + .HasColumnType("INTEGER"); + + b.Property("NetworkProtocol") + .HasColumnType("INTEGER"); + + b.Property("Spectators") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("MultiplayerModes"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Platform", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Platforms"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.PlaySession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("PlaySessions"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Redistributable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Redistributables"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Tool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Tools"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("LibraryGame", b => + { + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.HasKey("GameId", "LibraryId"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryGame"); + }); + + modelBuilder.Entity("CategoryGame", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Category", null) + .WithMany() + .HasForeignKey("CategoriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GamesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionGame", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Collection", null) + .WithMany() + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GameDeveloper", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Company", null) + .WithMany() + .HasForeignKey("DeveloperId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GameGenre", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GamesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GamePlatform", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GamesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Platform", null) + .WithMany() + .HasForeignKey("PlatformsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GamePublisher", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Company", null) + .WithMany() + .HasForeignKey("PublisherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GameRedistributable", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Redistributable", null) + .WithMany() + .HasForeignKey("RedistributableId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GameTag", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GamesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Category", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Category", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Game", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", "BaseGame") + .WithMany("DependentGames") + .HasForeignKey("BaseGameId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Launcher.Data.Models.Engine", "Engine") + .WithMany("Games") + .HasForeignKey("EngineId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("BaseGame"); + + b.Navigation("Engine"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.GameExternalId", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", "Game") + .WithMany("ExternalIds") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.GameTool", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", "Game") + .WithMany("GameTools") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Tool", "Tool") + .WithMany("GameTools") + .HasForeignKey("ToolId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + + b.Navigation("Tool"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Library", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.User", "User") + .WithOne("Library") + .HasForeignKey("LANCommander.Launcher.Data.Models.Library", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Media", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", "Game") + .WithMany("Media") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("LANCommander.Launcher.Data.Models.User", "User") + .WithOne("Avatar") + .HasForeignKey("LANCommander.Launcher.Data.Models.Media", "UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.MultiplayerMode", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", "Game") + .WithMany("MultiplayerModes") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.PlaySession", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", "Game") + .WithMany("PlaySessions") + .HasForeignKey("GameId"); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("LibraryGame", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Category", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Engine", b => + { + b.Navigation("Games"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Game", b => + { + b.Navigation("DependentGames"); + + b.Navigation("ExternalIds"); + + b.Navigation("GameTools"); + + b.Navigation("Media"); + + b.Navigation("MultiplayerModes"); + + b.Navigation("PlaySessions"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Tool", b => + { + b.Navigation("GameTools"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.User", b => + { + b.Navigation("Avatar"); + + b.Navigation("Library"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/LANCommander.Launcher.Data/Migrations/20260627164452_AddPerGameToolInstallState.cs b/LANCommander.Launcher.Data/Migrations/20260627164452_AddPerGameToolInstallState.cs new file mode 100644 index 00000000..4491ab0f --- /dev/null +++ b/LANCommander.Launcher.Data/Migrations/20260627164452_AddPerGameToolInstallState.cs @@ -0,0 +1,199 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LANCommander.Launcher.Data.Migrations +{ + /// + public partial class AddPerGameToolInstallState : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_GameTool_Games_GamesId", + table: "GameTool"); + + migrationBuilder.DropForeignKey( + name: "FK_GameTool_Tools_ToolsId", + table: "GameTool"); + + migrationBuilder.RenameColumn( + name: "ToolsId", + table: "GameTool", + newName: "ToolId"); + + migrationBuilder.RenameColumn( + name: "GamesId", + table: "GameTool", + newName: "GameId"); + + migrationBuilder.RenameIndex( + name: "IX_GameTool_ToolsId", + table: "GameTool", + newName: "IX_GameTool_ToolId"); + + migrationBuilder.AddColumn( + name: "InstallDirectory", + table: "GameTool", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Installed", + table: "GameTool", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "InstalledOn", + table: "GameTool", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "InstalledVersion", + table: "GameTool", + type: "TEXT", + nullable: true); + + // Tool install state was previously tracked globally on the Tools table. Migrate it onto + // the join row for the game the tool was actually installed under (the game whose install + // directory matches the tool's recorded directory) before the old columns are dropped. + migrationBuilder.Sql(@" + UPDATE GameTool + SET + Installed = 1, + InstallDirectory = (SELECT t.InstallDirectory FROM Tools t WHERE t.Id = GameTool.ToolId), + InstalledVersion = (SELECT t.InstalledVersion FROM Tools t WHERE t.Id = GameTool.ToolId), + InstalledOn = (SELECT t.InstalledOn FROM Tools t WHERE t.Id = GameTool.ToolId) + WHERE EXISTS ( + SELECT 1 + FROM Tools t + JOIN Games g ON g.Id = GameTool.GameId + WHERE t.Id = GameTool.ToolId + AND t.Installed = 1 + AND t.InstallDirectory IS NOT NULL + AND t.InstallDirectory = g.InstallDirectory + );"); + + migrationBuilder.DropColumn( + name: "InstallDirectory", + table: "Tools"); + + migrationBuilder.DropColumn( + name: "Installed", + table: "Tools"); + + migrationBuilder.DropColumn( + name: "InstalledOn", + table: "Tools"); + + migrationBuilder.DropColumn( + name: "InstalledVersion", + table: "Tools"); + + migrationBuilder.AddForeignKey( + name: "FK_GameTool_Games_GameId", + table: "GameTool", + column: "GameId", + principalTable: "Games", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_GameTool_Tools_ToolId", + table: "GameTool", + column: "ToolId", + principalTable: "Tools", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_GameTool_Games_GameId", + table: "GameTool"); + + migrationBuilder.DropForeignKey( + name: "FK_GameTool_Tools_ToolId", + table: "GameTool"); + + migrationBuilder.DropColumn( + name: "InstallDirectory", + table: "GameTool"); + + migrationBuilder.DropColumn( + name: "Installed", + table: "GameTool"); + + migrationBuilder.DropColumn( + name: "InstalledOn", + table: "GameTool"); + + migrationBuilder.DropColumn( + name: "InstalledVersion", + table: "GameTool"); + + migrationBuilder.RenameColumn( + name: "ToolId", + table: "GameTool", + newName: "ToolsId"); + + migrationBuilder.RenameColumn( + name: "GameId", + table: "GameTool", + newName: "GamesId"); + + migrationBuilder.RenameIndex( + name: "IX_GameTool_ToolId", + table: "GameTool", + newName: "IX_GameTool_ToolsId"); + + migrationBuilder.AddColumn( + name: "InstallDirectory", + table: "Tools", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Installed", + table: "Tools", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "InstalledOn", + table: "Tools", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "InstalledVersion", + table: "Tools", + type: "TEXT", + nullable: true); + + migrationBuilder.AddForeignKey( + name: "FK_GameTool_Games_GamesId", + table: "GameTool", + column: "GamesId", + principalTable: "Games", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_GameTool_Tools_ToolsId", + table: "GameTool", + column: "ToolsId", + principalTable: "Tools", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/LANCommander.Launcher.Data/Migrations/DatabaseContextModelSnapshot.cs b/LANCommander.Launcher.Data/Migrations/DatabaseContextModelSnapshot.cs index fba57e51..c6ed51a6 100644 --- a/LANCommander.Launcher.Data/Migrations/DatabaseContextModelSnapshot.cs +++ b/LANCommander.Launcher.Data/Migrations/DatabaseContextModelSnapshot.cs @@ -137,21 +137,6 @@ namespace LANCommander.Launcher.Data.Migrations b.ToTable("GameTag"); }); - modelBuilder.Entity("GameTool", b => - { - b.Property("GamesId") - .HasColumnType("TEXT"); - - b.Property("ToolsId") - .HasColumnType("TEXT"); - - b.HasKey("GamesId", "ToolsId"); - - b.HasIndex("ToolsId"); - - b.ToTable("GameTool"); - }); - modelBuilder.Entity("LANCommander.Launcher.Data.Models.Category", b => { b.Property("Id") @@ -355,6 +340,33 @@ namespace LANCommander.Launcher.Data.Migrations b.ToTable("GameExternalIds"); }); + modelBuilder.Entity("LANCommander.Launcher.Data.Models.GameTool", b => + { + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("ToolId") + .HasColumnType("TEXT"); + + b.Property("InstallDirectory") + .HasColumnType("TEXT"); + + b.Property("Installed") + .HasColumnType("INTEGER"); + + b.Property("InstalledOn") + .HasColumnType("TEXT"); + + b.Property("InstalledVersion") + .HasColumnType("TEXT"); + + b.HasKey("GameId", "ToolId"); + + b.HasIndex("ToolId"); + + b.ToTable("GameTool"); + }); + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Genre", b => { b.Property("Id") @@ -632,18 +644,6 @@ namespace LANCommander.Launcher.Data.Migrations b.Property("ImportedOn") .HasColumnType("TEXT"); - b.Property("InstallDirectory") - .HasColumnType("TEXT"); - - b.Property("Installed") - .HasColumnType("INTEGER"); - - b.Property("InstalledOn") - .HasColumnType("TEXT"); - - b.Property("InstalledVersion") - .HasColumnType("TEXT"); - b.Property("LatestVersion") .HasColumnType("TEXT"); @@ -823,21 +823,6 @@ namespace LANCommander.Launcher.Data.Migrations .IsRequired(); }); - modelBuilder.Entity("GameTool", b => - { - b.HasOne("LANCommander.Launcher.Data.Models.Game", null) - .WithMany() - .HasForeignKey("GamesId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("LANCommander.Launcher.Data.Models.Tool", null) - .WithMany() - .HasForeignKey("ToolsId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - modelBuilder.Entity("LANCommander.Launcher.Data.Models.Category", b => { b.HasOne("LANCommander.Launcher.Data.Models.Category", "Parent") @@ -874,6 +859,25 @@ namespace LANCommander.Launcher.Data.Migrations b.Navigation("Game"); }); + modelBuilder.Entity("LANCommander.Launcher.Data.Models.GameTool", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", "Game") + .WithMany("GameTools") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Tool", "Tool") + .WithMany("GameTools") + .HasForeignKey("ToolId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + + b.Navigation("Tool"); + }); + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Library", b => { b.HasOne("LANCommander.Launcher.Data.Models.User", "User") @@ -952,6 +956,8 @@ namespace LANCommander.Launcher.Data.Migrations b.Navigation("ExternalIds"); + b.Navigation("GameTools"); + b.Navigation("Media"); b.Navigation("MultiplayerModes"); @@ -959,6 +965,11 @@ namespace LANCommander.Launcher.Data.Migrations b.Navigation("PlaySessions"); }); + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Tool", b => + { + b.Navigation("GameTools"); + }); + modelBuilder.Entity("LANCommander.Launcher.Data.Models.User", b => { b.Navigation("Avatar"); diff --git a/LANCommander.Launcher.Data/Models/Game.cs b/LANCommander.Launcher.Data/Models/Game.cs index 3edb106a..9cb55495 100644 --- a/LANCommander.Launcher.Data/Models/Game.cs +++ b/LANCommander.Launcher.Data/Models/Game.cs @@ -44,6 +44,7 @@ namespace LANCommander.Launcher.Data.Models public virtual ICollection? Platforms { get; set; } = new List(); public virtual ICollection? Redistributables { get; set; } = new List(); public virtual ICollection? Tools { get; set; } = new List(); + public virtual ICollection GameTools { get; set; } = new List(); public virtual ICollection? Media { get; set; } = new List(); public virtual ICollection Collections { get; set; } = new List(); public virtual ICollection DependentGames { get; set; } = new List(); diff --git a/LANCommander.Launcher.Data/Models/GameTool.cs b/LANCommander.Launcher.Data/Models/GameTool.cs new file mode 100644 index 00000000..46e85d03 --- /dev/null +++ b/LANCommander.Launcher.Data/Models/GameTool.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace LANCommander.Launcher.Data.Models +{ + /// + /// Join entity between and that tracks the install + /// state of a tool for a specific game. Because a tool can be shared by multiple games and is + /// installed into each game's own directory, the install state must be tracked per game rather + /// than on the tool itself. + /// + [Table("GameTool")] + public class GameTool + { + public Guid GameId { get; set; } + public virtual Game Game { get; set; } + + public Guid ToolId { get; set; } + public virtual Tool Tool { get; set; } + + public bool Installed { get; set; } + public string? InstallDirectory { get; set; } + public string? InstalledVersion { get; set; } + public DateTime? InstalledOn { get; set; } + } +} diff --git a/LANCommander.Launcher.Data/Models/Tool.cs b/LANCommander.Launcher.Data/Models/Tool.cs index 6b46bd0d..872020d6 100644 --- a/LANCommander.Launcher.Data/Models/Tool.cs +++ b/LANCommander.Launcher.Data/Models/Tool.cs @@ -8,13 +8,10 @@ namespace LANCommander.Launcher.Data.Models public string Name { get; set; } public string? Description { get; set; } public string? Notes { get; set; } - - public bool Installed { get; set; } - public string? InstallDirectory { get; set; } - public string? InstalledVersion { get; set; } - public DateTime? InstalledOn { get; set; } + public string? LatestVersion { get; set; } - + public virtual ICollection? Games { get; set; } = new List(); + public virtual ICollection GameTools { get; set; } = new List(); } } diff --git a/LANCommander.Launcher.Models/InstallQueueGame.cs b/LANCommander.Launcher.Models/InstallQueueGame.cs index 16c0676d..8ee0ced2 100644 --- a/LANCommander.Launcher.Models/InstallQueueGame.cs +++ b/LANCommander.Launcher.Models/InstallQueueGame.cs @@ -7,6 +7,7 @@ namespace LANCommander.Launcher.Models { public Guid Id { get; set; } public Guid[] AddonIds { get; set; } + public Guid[] ToolIds { get; set; } public Dictionary AddonVersions { get; set; } public string Title { get; set; } public string Version { get; set; } diff --git a/LANCommander.Launcher.Services.Tests/Tests/ImportServiceTests.cs b/LANCommander.Launcher.Services.Tests/Tests/ImportServiceTests.cs new file mode 100644 index 00000000..1ff9637f --- /dev/null +++ b/LANCommander.Launcher.Services.Tests/Tests/ImportServiceTests.cs @@ -0,0 +1,218 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using LANCommander.Launcher.Data; +using LANCommander.Launcher.Data.Models; +using LANCommander.Launcher.Services.Tests.Helpers; +using LANCommander.SDK.Abstractions; +using LANCommander.SDK.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Shouldly; +using Xunit; +using Game = LANCommander.Launcher.Data.Models.Game; + +namespace LANCommander.Launcher.Services.Tests.Tests; + +public class ImportServiceTests +{ + private static DatabaseContext CreateContext() => + new( + NullLoggerFactory.Instance, + new DbContextOptionsBuilder() + .UseInMemoryDatabase($"ImportServiceTests-{Guid.NewGuid()}") + .Options); + + private static AuthenticationService CreateAuthService(Guid userId) + { + var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId.ToString()) }; + var jwt = new JwtSecurityTokenHandler().WriteToken(new JwtSecurityToken( + issuer: "test", + audience: "test", + claims: claims, + expires: DateTime.UtcNow.AddMinutes(5))); + + var tokenProvider = new Mock(); + tokenProvider.Setup(p => p.GetToken()).Returns(new AuthToken { AccessToken = jwt }); + + // Only the token-reading path (GetUserId) is exercised, so the rest of the graph is null!. + return new AuthenticationService( + tokenProvider.Object, + settingsProvider: null!, + scopeFactory: null!, + connectionClient: null!, + authenticationClient: null!, + logger: NullLogger.Instance); + } + + private static ImportService CreateSubject(DatabaseContext context, Guid userId) => + // Reconciliation only touches the database context, the logger, and the authentication + // service. The remaining ctor args are never dereferenced so we pass null! rather than + // build mocks for the concrete SDK clients (which expose non-virtual methods anyway). + new( + NullLogger.Instance, + importContextFactory: null!, + gameClient: null!, + toolClient: null!, + libraryClient: null!, + playSessionClient: null!, + dbContext: context, + gameService: null!, + authenticationService: CreateAuthService(userId)); + + private static async Task SeedLibraryAsync(DatabaseContext context, Guid userId, params Game[] games) + { + context.Libraries!.Add(new Library + { + UserId = userId, + Games = games.ToList(), + }); + + await context.SaveChangesAsync(); + } + + private static async Task SeedCachedGamesAsync(DatabaseContext context, params Game[] games) + { + // Games that exist in the local database but are not associated with any library, mirroring + // records left behind after they were dropped from the library on a previous import. + context.Games!.AddRange(games); + + await context.SaveChangesAsync(); + } + + private static async Task> GetLibraryGameIdsAsync(DatabaseContext context, Guid userId) + { + context.ChangeTracker.Clear(); + + var library = await context.Libraries! + .Include(l => l.Games) + .FirstAsync(l => l.UserId == userId); + + return library.Games.Select(g => g.Id).ToList(); + } + + [Fact] + public async Task ReconcileLibraryMembership_removes_local_games_missing_from_remote_library() + { + var userId = Guid.NewGuid(); + var keep = GameFactory.Make("Half-Life"); + var stale = GameFactory.Make("Removed From Depot"); + + await using var context = CreateContext(); + await SeedLibraryAsync(context, userId, keep, stale); + + await CreateSubject(context, userId).ReconcileLibraryMembershipAsync([keep.Id]); + + var remaining = await GetLibraryGameIdsAsync(context, userId); + remaining.ShouldBe([keep.Id]); + } + + [Fact] + public async Task ReconcileLibraryMembership_keeps_games_still_present_remotely() + { + var userId = Guid.NewGuid(); + var first = GameFactory.Make("Half-Life"); + var second = GameFactory.Make("Quake III Arena"); + + await using var context = CreateContext(); + await SeedLibraryAsync(context, userId, first, second); + + await CreateSubject(context, userId).ReconcileLibraryMembershipAsync([first.Id, second.Id]); + + var remaining = await GetLibraryGameIdsAsync(context, userId); + remaining.ShouldBe([first.Id, second.Id], ignoreOrder: true); + } + + [Fact] + public async Task ReconcileLibraryMembership_skips_when_remote_library_is_empty() + { + // An empty remote list is ambiguous with a server-side failure, so the local + // library must be left untouched rather than wiped. + var userId = Guid.NewGuid(); + var game = GameFactory.Make("Half-Life"); + + await using var context = CreateContext(); + await SeedLibraryAsync(context, userId, game); + + await CreateSubject(context, userId).ReconcileLibraryMembershipAsync([]); + + var remaining = await GetLibraryGameIdsAsync(context, userId); + remaining.ShouldBe([game.Id]); + } + + [Fact] + public async Task ReconcileLibraryMembership_does_not_touch_other_users_libraries() + { + var userId = Guid.NewGuid(); + var otherUserId = Guid.NewGuid(); + var ownGame = GameFactory.Make("Half-Life"); + var otherGame = GameFactory.Make("Quake III Arena"); + + await using var context = CreateContext(); + await SeedLibraryAsync(context, userId, ownGame); + await SeedLibraryAsync(context, otherUserId, otherGame); + + // Remote library for the current user no longer contains any games it shares with the + // other user, but the other user's library must remain intact. + await CreateSubject(context, userId).ReconcileLibraryMembershipAsync([ownGame.Id]); + + var otherRemaining = await GetLibraryGameIdsAsync(context, otherUserId); + otherRemaining.ShouldBe([otherGame.Id]); + } + + [Fact] + public async Task ReconcileLibraryMembership_adds_cached_games_missing_from_library() + { + // Reproduces toggling "Enable User Libraries" off: the game was dropped from the library on + // a previous import but its record is still cached, so a subsequent full library must + // re-associate it instead of leaving it hidden. + var userId = Guid.NewGuid(); + var inLibrary = GameFactory.Make("Half-Life"); + var cachedOnly = GameFactory.Make("Quake III Arena"); + + await using var context = CreateContext(); + await SeedLibraryAsync(context, userId, inLibrary); + await SeedCachedGamesAsync(context, cachedOnly); + + await CreateSubject(context, userId).ReconcileLibraryMembershipAsync([inLibrary.Id, cachedOnly.Id]); + + var remaining = await GetLibraryGameIdsAsync(context, userId); + remaining.ShouldBe([inLibrary.Id, cachedOnly.Id], ignoreOrder: true); + } + + [Fact] + public async Task ReconcileLibraryMembership_adds_and_removes_in_a_single_pass() + { + var userId = Guid.NewGuid(); + var keep = GameFactory.Make("Half-Life"); + var stale = GameFactory.Make("Removed From Depot"); + var cachedOnly = GameFactory.Make("Quake III Arena"); + + await using var context = CreateContext(); + await SeedLibraryAsync(context, userId, keep, stale); + await SeedCachedGamesAsync(context, cachedOnly); + + await CreateSubject(context, userId).ReconcileLibraryMembershipAsync([keep.Id, cachedOnly.Id]); + + var remaining = await GetLibraryGameIdsAsync(context, userId); + remaining.ShouldBe([keep.Id, cachedOnly.Id], ignoreOrder: true); + } + + [Fact] + public async Task ReconcileLibraryMembership_ignores_remote_games_not_cached_locally() + { + // A remote game whose record has not been imported yet cannot be added to the library here; + // reconciliation must simply leave it out rather than fail. + var userId = Guid.NewGuid(); + var inLibrary = GameFactory.Make("Half-Life"); + var notCachedRemoteId = Guid.NewGuid(); + + await using var context = CreateContext(); + await SeedLibraryAsync(context, userId, inLibrary); + + await CreateSubject(context, userId).ReconcileLibraryMembershipAsync([inLibrary.Id, notCachedRemoteId]); + + var remaining = await GetLibraryGameIdsAsync(context, userId); + remaining.ShouldBe([inLibrary.Id]); + } +} diff --git a/LANCommander.Launcher.Services/Extensions/ServiceCollectionExtensions.cs b/LANCommander.Launcher.Services/Extensions/ServiceCollectionExtensions.cs index 6e20a892..74382799 100644 --- a/LANCommander.Launcher.Services/Extensions/ServiceCollectionExtensions.cs +++ b/LANCommander.Launcher.Services/Extensions/ServiceCollectionExtensions.cs @@ -35,6 +35,8 @@ namespace LANCommander.Launcher.Services.Extensions services.AddSingleton(); #endregion + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => diff --git a/LANCommander.Launcher.Services/GameService.cs b/LANCommander.Launcher.Services/GameService.cs index 7fdc2164..20bb66e6 100644 --- a/LANCommander.Launcher.Services/GameService.cs +++ b/LANCommander.Launcher.Services/GameService.cs @@ -19,6 +19,8 @@ namespace LANCommander.Launcher.Services PlaySessionService playSessionService, ProfileClient profileClient, GameClient gameClient, + ToolService toolService, + ToolClient toolClient, IConnectionClient connectionClient, IServiceProvider serviceProvider) : BaseDatabaseService(dbContext, logger) { @@ -68,6 +70,26 @@ namespace LANCommander.Launcher.Services } } + // Uninstall any tools that were installed for this game. Tools are installed + // into the game's own directory and tracked per game, so uninstalling this + // game only removes its copy and leaves the tool intact for other games that + // share it. This must run before ClearGameState clears the install directory. + var installedTools = await toolService.GetInstalledToolsForGameAsync(game.Id); + + foreach (var gameTool in installedTools) + { + try + { + await toolClient.UninstallAsync(game.InstallDirectory, gameTool.ToolId); + + await toolService.SetToolUninstalledAsync(game.Id, gameTool.ToolId); + } + catch (Exception ex) + { + Logger?.LogError(ex, "Could not uninstall tool {ToolId} from game {GameId}", gameTool.ToolId, game.Id); + } + } + ClearGameState(game); await UpdateAsync(game); diff --git a/LANCommander.Launcher.Services/Import/Importers/GameImporter.cs b/LANCommander.Launcher.Services/Import/Importers/GameImporter.cs index 8812cbe0..41db6fd1 100644 --- a/LANCommander.Launcher.Services/Import/Importers/GameImporter.cs +++ b/LANCommander.Launcher.Services/Import/Importers/GameImporter.cs @@ -32,9 +32,9 @@ public class GameImporter( return record.UpdatedOn > existing.ImportedOn || - record.Actions.Any(a => a.UpdatedOn > existing.ImportedOn || a.CreatedOn > existing.ImportedOn) + (record.Actions?.Any(a => a.UpdatedOn > existing.ImportedOn || a.CreatedOn > existing.ImportedOn) ?? false) || - record.SavePaths.Any(a => a.UpdatedOn > existing.ImportedOn || a.CreatedOn > existing.ImportedOn); + (record.SavePaths?.Any(a => a.UpdatedOn > existing.ImportedOn || a.CreatedOn > existing.ImportedOn) ?? false); } public override async Task AddAsync(ImportItemInfo importItemInfo) diff --git a/LANCommander.Launcher.Services/Import/Importers/ToolImporter.cs b/LANCommander.Launcher.Services/Import/Importers/ToolImporter.cs index 8193361d..34aad012 100644 --- a/LANCommander.Launcher.Services/Import/Importers/ToolImporter.cs +++ b/LANCommander.Launcher.Services/Import/Importers/ToolImporter.cs @@ -8,7 +8,6 @@ namespace LANCommander.Launcher.Services.Import.Importers; public class ToolImporter( ToolService toolService, - LibraryService libraryService, ILogger logger) : BaseImporter { public override async Task> GetImportInfoAsync(Tool record, BaseManifest manifest) => @@ -32,7 +31,7 @@ public class ToolImporter( return record.UpdatedOn > existing.ImportedOn || - record.Actions.Any(a => a.UpdatedOn > existing.ImportedOn || a.CreatedOn > existing.ImportedOn); + (record.Actions?.Any(a => a.UpdatedOn > existing.ImportedOn || a.CreatedOn > existing.ImportedOn) ?? false); } public override async Task AddAsync(ImportItemInfo importItemInfo) @@ -77,9 +76,14 @@ public class ToolImporter( await toolService.UpdateAsync(existing); await UpdateRelationships(importItemInfo.Record); - - if (await libraryService.IsInstalledAsync(existing.Id) && existing.LatestVersion == existing.InstalledVersion) - await ManifestHelper.WriteAsync(importItemInfo.Record, existing.InstallDirectory); + + // Refresh the on-disk manifest for every game this tool is installed for and up to date + // on. Install state is tracked per game because a tool can be shared by several games. + foreach (var gameTool in await toolService.GetInstalledGameToolsAsync(existing.Id)) + { + if (existing.LatestVersion == gameTool.InstalledVersion && !string.IsNullOrEmpty(gameTool.InstallDirectory)) + await ManifestHelper.WriteAsync(importItemInfo.Record, gameTool.InstallDirectory); + } return true; } diff --git a/LANCommander.Launcher.Services/ImportService.cs b/LANCommander.Launcher.Services/ImportService.cs index a3554303..e5f659dc 100644 --- a/LANCommander.Launcher.Services/ImportService.cs +++ b/LANCommander.Launcher.Services/ImportService.cs @@ -17,7 +17,8 @@ namespace LANCommander.Launcher.Services LibraryClient libraryClient, PlaySessionClient playSessionClient, DatabaseContext dbContext, - GameService gameService) : BaseService(logger) + GameService gameService, + AuthenticationService authenticationService) : BaseService(logger) { private const int MaxConcurrentManifestFetches = 8; @@ -96,10 +97,78 @@ namespace LANCommander.Launcher.Services await importContext.ImportQueueAsync(); await importContext.DownloadPendingMediaAsync(); + // Make the local library membership match the remote library exactly + await ReconcileLibraryMembershipAsync(remoteLibrary.Select(g => g.Id).ToList()); + // Sync play sessions for all library games await SyncPlaySessionsAsync(remoteLibrary.Select(g => g.Id)); } + internal async Task ReconcileLibraryMembershipAsync(IReadOnlyCollection remoteGameIds) + { + // The library endpoint returns an empty list both when the library is genuinely + // empty and when it fails server-side. Treating an empty result as authoritative + // would wipe the entire local library on a transient error, so skip reconciliation + // in that case. + if (remoteGameIds.Count == 0) + { + Logger?.LogDebug("Skipping library reconciliation because the remote library returned no games"); + return; + } + + var userId = authenticationService.GetUserId(); + + var library = await dbContext.Libraries + .Include(l => l.Games) + .FirstOrDefaultAsync(l => l.UserId == userId); + + if (library == null) + return; + + var remoteGameIdSet = remoteGameIds.ToHashSet(); + + // Remove games that are no longer present in the remote library. + var staleGames = library.Games + .Where(g => !remoteGameIdSet.Contains(g.Id)) + .ToList(); + + foreach (var staleGame in staleGames) + library.Games.Remove(staleGame); + + // Add games that belong in the remote library and already exist locally but are + // missing from the local library. The import above skips games whose cached records + // are unchanged, so toggling "Enable User Libraries" off on the server (which makes + // the endpoint return every game) would otherwise leave previously-dropped games + // hidden from the library view. + var localGameIds = library.Games + .Select(g => g.Id) + .ToHashSet(); + + var missingGameIds = remoteGameIdSet + .Where(id => !localGameIds.Contains(id)) + .ToList(); + + var gamesToAdd = missingGameIds.Count == 0 + ? new List() + : await dbContext.Games + .Where(g => missingGameIds.Contains(g.Id)) + .ToListAsync(); + + foreach (var game in gamesToAdd) + library.Games.Add(game); + + if (staleGames.Count == 0 && gamesToAdd.Count == 0) + return; + + await dbContext.SaveChangesAsync(); + + if (staleGames.Count > 0) + Logger?.LogInformation("Removed {Count} game(s) from the local library that are no longer present on the server", staleGames.Count); + + if (gamesToAdd.Count > 0) + Logger?.LogInformation("Added {Count} game(s) to the local library that were already cached locally", gamesToAdd.Count); + } + public async Task ImportGameAsync(Guid gameId) { var importContext = importContextFactory.Create(); diff --git a/LANCommander.Launcher.Services/InstallService.cs b/LANCommander.Launcher.Services/InstallService.cs index ddacd6d3..bcc1e4f3 100644 --- a/LANCommander.Launcher.Services/InstallService.cs +++ b/LANCommander.Launcher.Services/InstallService.cs @@ -9,6 +9,7 @@ using System.Collections.ObjectModel; using System.Diagnostics; using LANCommander.SDK.Models; using LANCommander.SDK.Services; +using Microsoft.EntityFrameworkCore; using Game = LANCommander.Launcher.Data.Models.Game; using Tool = LANCommander.Launcher.Data.Models.Tool; @@ -41,9 +42,20 @@ namespace LANCommander.Launcher.Services public delegate Task OnInstallCompleteHandler(Game game); public event OnInstallCompleteHandler OnInstallComplete; + public delegate Task OnToolInstallCompleteHandler(Game game); + public event OnToolInstallCompleteHandler OnToolInstallComplete; + + public delegate Task OnInstallQueueCompleteHandler(Game game); + public event OnInstallQueueCompleteHandler OnInstallQueueComplete; + public delegate Task OnInstallFailHandler(Game game); public event OnInstallFailHandler OnInstallFail; + // Root game ids the user initiated this session that have not yet had a + // batch-complete notification fired. Used to scope notifications to active + // installs and to fire a single notification once a whole group settles. + private readonly HashSet _pendingNotificationRoots = new(); + public InstallService( ILogger logger, GameService gameService, @@ -79,11 +91,13 @@ namespace LANCommander.Launcher.Services OnProgress?.Invoke(e); }; - _redistributableClient.OnInstallProgressUpdate += (e) => - { - UpdateQueueItemFromProgress(e); - OnProgress?.Invoke(e); - }; + // Note: RedistributableClient progress is intentionally NOT forwarded here. + // Its InstallProgress never carries a Game, so it can't be matched to a queue + // item, and redistributables are also installed/verified during game launch — + // forwarding those events would drive the queue footer and taskbar with + // out-of-band progress when nothing is actually queued. The game-level + // "Installing Redistributables" status (raised by GameClient with the owning + // game attached) still surfaces the redist phase of a queued install. // New task-level progress forwarding _gameClient.OnTaskProgress += OnSdkTaskProgress; @@ -228,6 +242,9 @@ namespace LANCommander.Launcher.Services a => a.Archives?.OrderByDescending(ar => ar.CreatedOn).FirstOrDefault()?.Version); } + if (planItem.Type == InstallPlanItemType.Game) + ((InstallQueueGame)queueItem).ToolIds = toolIds ?? []; + // Flag as update if game is already installed with a different version if (game.Installed && !string.IsNullOrWhiteSpace(queueItem.Version) && queueItem.Version != game.InstalledVersion) @@ -264,6 +281,10 @@ namespace LANCommander.Launcher.Services Queue.Count, string.Join(", ", Queue.Select(i => $"{i.Title}({i.Status}, depends={i.DependsOnId})"))); + // Track this root so a single batch-complete notification fires once the + // whole group (base game + addons/redists/tools) has settled. + _pendingNotificationRoots.Add(game.Id); + // Start processing if nothing active if (!Queue.Any(i => i.State)) { @@ -442,6 +463,68 @@ namespace LANCommander.Launcher.Services } Logger?.LogInformation("[InstallQueue] Next: No eligible items found to process"); + + // The queue has settled (nothing eligible to process). Fire a single + // batch-complete notification for any tracked root whose entire group has + // finished. + await NotifySettledGroups(); + } + + // Walks the DependsOnId chain up to the root install item (the base game with no + // dependency) so an item can be attributed to its install group. + private Guid ResolveRootId(IInstallQueueItem item) + { + var current = item; + var visited = new HashSet(); + + while (current.DependsOnId.HasValue && visited.Add(current.Id)) + { + var parent = Queue.FirstOrDefault(i => i.Id == current.DependsOnId.Value); + + // Parent no longer in queue (assumed complete) — treat the dependency id as the root. + if (parent == null) + return current.DependsOnId.Value; + + current = parent; + } + + return current.Id; + } + + private async Task NotifySettledGroups() + { + foreach (var rootId in _pendingNotificationRoots.ToList()) + { + var groupItems = Queue.Where(i => ResolveRootId(i) == rootId).ToList(); + + // No items map to this root (e.g. user installed an addon whose real root + // is its base game) — nothing to notify for, drop it. + if (groupItems.Count == 0) + { + _pendingNotificationRoots.Remove(rootId); + continue; + } + + var allTerminal = groupItems.All(i => + i.Status.ValueIsIn(InstallStatus.Complete, InstallStatus.Failed, InstallStatus.Canceled)); + + var rootItem = groupItems.FirstOrDefault(i => i.Id == rootId); + + // Wait until everything in the group has settled, and only announce + // completion when the base game itself actually installed. + if (!allTerminal || rootItem == null || rootItem.Status != InstallStatus.Complete) + continue; + + _pendingNotificationRoots.Remove(rootId); + + var rootGame = await _gameService.GetAsync(rootId); + + if (rootGame != null) + { + Logger?.LogInformation("[InstallQueue] NotifySettledGroups: Install batch complete for {Title} ({Id}), firing notification", rootGame.Title, rootGame.Id); + OnInstallQueueComplete?.Invoke(rootGame); + } + } } private async Task Next(InstallQueueGame queueItem) @@ -511,7 +594,7 @@ namespace LANCommander.Launcher.Services // Probably doing a modification of some sort if (localGame.InstallDirectory.StartsWith(queueItem.InstallDirectory)) { - var allAddons = remoteGame.DependentGames.ToArray(); + var allAddons = (remoteGame.DependentGames ?? []).ToArray(); var removeAddons = allAddons.Except(queueItem.AddonIds ?? []).ToArray(); var addAddons = allAddons.Intersect(queueItem.AddonIds ?? []).ToArray(); @@ -519,6 +602,26 @@ namespace LANCommander.Launcher.Services var installResult = await _gameClient.InstallAddonsAsync(localGame.InstallDirectory, localGame.Id, addAddons); await _gameClient.RestoreFilesAsync(localGame.InstallDirectory, localGame.Id, uninstallResult.FileList, installResult.FileList); + // Uninstall any tools that were deselected. Selected tools are installed + // via their own queue items, so we only handle removal here. Tool install + // state is tracked per game, so this only affects this game's copy. + var selectedToolIds = queueItem.ToolIds ?? []; + var installedTools = await _toolService.GetInstalledToolsForGameAsync(localGame.Id); + + foreach (var gameTool in installedTools.Where(gt => !selectedToolIds.Contains(gt.ToolId))) + { + try + { + await _toolClient.UninstallAsync(localGame.InstallDirectory, gameTool.ToolId); + + await _toolService.SetToolUninstalledAsync(localGame.Id, gameTool.ToolId); + } + catch (Exception ex) + { + Logger?.LogError(ex, "Could not uninstall tool {ToolId} from game {GameId}", gameTool.ToolId, localGame.Id); + } + } + UpdateGameState(queueItem, localGame, localGame.InstallDirectory); UpdateAddonStates(queueItem, localGame); await _gameService.UpdateAsync(localGame); @@ -581,7 +684,10 @@ namespace LANCommander.Launcher.Services return; } - if (localTool.Installed) + var alreadyInstalled = queueItem.DependsOnId.HasValue + && await _toolService.IsToolInstalledForGameAsync(queueItem.DependsOnId.Value, localTool.Id); + + if (alreadyInstalled) { // Modify — currently no-op } @@ -765,6 +871,19 @@ namespace LANCommander.Launcher.Services // Update manifest and scripts on disk await _gameClient.UpdateGameInstallationAsync(localGame.InstallDirectory, remoteGame); + + // Bug #1 convergence: after applying all updates and re-importing, the installed + // version may still trail the server's resolved latest version (the last applied + // archive's version string is not guaranteed to equal the canonical latest version). + // Converge explicitly so the game leaves the "update available" state. + if (!string.IsNullOrWhiteSpace(localGame.LatestVersion)) + { + localGame.InstalledVersion = localGame.LatestVersion; + await _gameService.UpdateAsync(localGame); + + Logger?.LogInformation("[InstallQueue] Update(Game): Converged InstalledVersion to LatestVersion {LatestVersion} for {Title}", + localGame.LatestVersion, currentItem.Title); + } } else { @@ -831,6 +950,8 @@ namespace LANCommander.Launcher.Services currentItem.Status = InstallStatus.Downloading; OnQueueChanged?.Invoke(); + string toolInstallDirectory = null; + try { var planItem = new InstallPlanItem @@ -844,7 +965,7 @@ namespace LANCommander.Launcher.Services var result = await _toolClient.ExecuteInstallPlanItemAsync(planItem, currentItem.CancellationToken.Token); - UpdateToolState(currentItem, localTool, result.InstallDirectory); + toolInstallDirectory = result.InstallDirectory; } catch (InstallCanceledException ex) { @@ -872,7 +993,12 @@ namespace LANCommander.Launcher.Services try { - await _toolService.UpdateAsync(localTool); + // Install state is tracked per game because a tool can be shared by several + // games and is installed into each game's own directory. + if (currentItem.DependsOnId.HasValue) + await _toolService.SetToolInstalledAsync(currentItem.DependsOnId.Value, localTool.Id, toolInstallDirectory, currentItem.Version); + else + Logger?.LogWarning("Tool {ToolName} ({ToolId}) was installed without an associated game; install state not recorded", localTool.Name, localTool.Id); } catch (Exception ex) { @@ -883,6 +1009,22 @@ namespace LANCommander.Launcher.Services Logger?.LogTrace("Install of tool {ToolName} ({ToolId}) complete!", localTool.Name, localTool.Id); + // Refresh the dependent game's action bar + if (currentItem.DependsOnId.HasValue) + { + try + { + var dependentGame = await _gameService.GetAsync(currentItem.DependsOnId.Value); + + if (dependentGame != null) + OnToolInstallComplete?.Invoke(dependentGame); + } + catch (Exception ex) + { + Logger?.LogError(ex, "Failed to refresh actions for game {GameId} after install of tool {ToolId}", currentItem.DependsOnId, localTool.Id); + } + } + operation.Complete(); } @@ -1094,14 +1236,6 @@ namespace LANCommander.Launcher.Services } } - private static void UpdateToolState(InstallQueueTool currentItem, Tool localTool, string installDirectory) - { - localTool.InstallDirectory = installDirectory; - localTool.Installed = true; - localTool.InstalledVersion = currentItem.Version; - localTool.InstalledOn ??= DateTime.Now; - } - public async Task Move(IInstallQueueItem currentItem, Game localGame, SDK.Models.Game remoteGame) { using (var operation = Logger.BeginOperation("Moving game {GameTitle} ({GameId}) to {Destination}", localGame.Title, localGame.Id, currentItem.InstallDirectory)) diff --git a/LANCommander.Launcher.Services/LANCommander.Launcher.Services.csproj b/LANCommander.Launcher.Services/LANCommander.Launcher.Services.csproj index 9b0dcf74..56ed2597 100644 --- a/LANCommander.Launcher.Services/LANCommander.Launcher.Services.csproj +++ b/LANCommander.Launcher.Services/LANCommander.Launcher.Services.csproj @@ -11,6 +11,10 @@ + + + + diff --git a/LANCommander.Launcher.Services/LibraryService.cs b/LANCommander.Launcher.Services/LibraryService.cs index fdcfdbc1..a25196ef 100644 --- a/LANCommander.Launcher.Services/LibraryService.cs +++ b/LANCommander.Launcher.Services/LibraryService.cs @@ -174,6 +174,16 @@ namespace LANCommander.Launcher.Services .AnyAsync(g => g.Id == gameId && g.Libraries.Any(l => l.UserId == userId)); } + public async Task> GetLibraryGameIdsAsync() + { + var userId = AuthenticationService.GetUserId(); + var ids = await Context.Games + .Where(g => g.Libraries.Any(l => l.UserId == userId)) + .Select(g => g.Id) + .ToListAsync(); + return ids.ToHashSet(); + } + public async Task> GetItemsAsync() { Items.Clear(); diff --git a/LANCommander.Launcher.Services/PowerShell/CurrentProcessInfo.cs b/LANCommander.Launcher.Services/PowerShell/CurrentProcessInfo.cs new file mode 100644 index 00000000..f805d7ed --- /dev/null +++ b/LANCommander.Launcher.Services/PowerShell/CurrentProcessInfo.cs @@ -0,0 +1,26 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Security.Principal; + +namespace LANCommander.Launcher.Services; + +public class CurrentProcessInfo : ICurrentProcessInfo +{ + public string ExecutablePath => Process.GetCurrentProcess().MainModule!.FileName; + + public bool IsElevated + { + get + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + using var identity = WindowsIdentity.GetCurrent(); + var principal = new WindowsPrincipal(identity); + + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } + + return Environment.UserName == "root"; + } + } +} diff --git a/LANCommander.Launcher.Services/PowerShell/ElevatedProcessLauncher.cs b/LANCommander.Launcher.Services/PowerShell/ElevatedProcessLauncher.cs new file mode 100644 index 00000000..2c9e8136 --- /dev/null +++ b/LANCommander.Launcher.Services/PowerShell/ElevatedProcessLauncher.cs @@ -0,0 +1,22 @@ +using System.Diagnostics; +using System.Threading.Tasks; + +namespace LANCommander.Launcher.Services; + +public class ElevatedProcessLauncher : IElevatedProcessLauncher +{ + public async Task LaunchAndWaitAsync(ElevatedProcessRequest request) + { + using var process = new Process(); + + process.StartInfo.FileName = request.FileName; + process.StartInfo.Verb = "runas"; + process.StartInfo.UseShellExecute = true; + process.StartInfo.WorkingDirectory = request.WorkingDirectory; + process.StartInfo.Arguments = request.Arguments; + + process.Start(); + + await process.WaitForExitAsync(); + } +} diff --git a/LANCommander.Launcher.Services/PowerShell/ElevatedScriptInterceptor.cs b/LANCommander.Launcher.Services/PowerShell/ElevatedScriptInterceptor.cs index 8f4bfc40..3f65a3b2 100644 --- a/LANCommander.Launcher.Services/PowerShell/ElevatedScriptInterceptor.cs +++ b/LANCommander.Launcher.Services/PowerShell/ElevatedScriptInterceptor.cs @@ -1,35 +1,19 @@ -using System.Diagnostics; -using System.Runtime.InteropServices; -using System.Security.Principal; using CommandLine; using LANCommander.Launcher.Models; -using LANCommander.SDK; using LANCommander.SDK.Enums; using LANCommander.SDK.PowerShell; namespace LANCommander.Launcher.Services; -public class ElevatedScriptInterceptor : IScriptInterceptor +public class ElevatedScriptInterceptor( + ICurrentProcessInfo currentProcessInfo, + IElevatedProcessLauncher processLauncher) : IScriptInterceptor { public async Task ExecuteAsync(PowerShellScript script) { try { - bool isElevated = false; - - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var identity = WindowsIdentity.GetCurrent(); - var principal = new WindowsPrincipal(identity); - - isElevated = principal.IsInRole(WindowsBuiltInRole.Administrator); - } - else - { - isElevated = Environment.UserName == "root"; - } - - if (script.RunAsAdmin && !isElevated) + if (script.RunAsAdmin && !currentProcessInfo.IsElevated) { var manifest = script.Variables.GetValue("GameManifest"); @@ -50,28 +34,26 @@ public class ElevatedScriptInterceptor : IScriptInterceptor } var arguments = Parser.Default.FormatCommandLine(options); - var path = Process.GetCurrentProcess().MainModule!.FileName; - var process = new Process(); - - process.StartInfo.FileName = path; - process.StartInfo.Verb = "runas"; - process.StartInfo.UseShellExecute = true; - process.StartInfo.WorkingDirectory = script.WorkingDirectory; - process.StartInfo.Arguments = arguments; - - process.Start(); - - await process.WaitForExitAsync(); + // Re-launch this launcher as a minimal, elevated process that runs just this script + // (with all its runtime parameters) and then exits. Wait until it has finished before + // reporting the script as handled so the caller doesn't continue prematurely. + await processLauncher.LaunchAndWaitAsync(new ElevatedProcessRequest + { + FileName = currentProcessInfo.ExecutablePath, + Arguments = arguments, + WorkingDirectory = script.WorkingDirectory, + }); return true; } } - catch (Exception ex) + catch (Exception) { - // Not running as admin + // Unable to determine elevation state or launch the elevated process; fall back to + // running the script in-process. } return false; } -} \ No newline at end of file +} diff --git a/LANCommander.Launcher.Services/PowerShell/ICurrentProcessInfo.cs b/LANCommander.Launcher.Services/PowerShell/ICurrentProcessInfo.cs new file mode 100644 index 00000000..89359668 --- /dev/null +++ b/LANCommander.Launcher.Services/PowerShell/ICurrentProcessInfo.cs @@ -0,0 +1,21 @@ +namespace LANCommander.Launcher.Services; + +/// +/// Exposes information about the currently running launcher process that the +/// needs in order to decide whether a script must be +/// re-launched with elevated privileges. Abstracted so the elevation decision can be tested without +/// depending on the real process token. +/// +public interface ICurrentProcessInfo +{ + /// + /// The full path to the executable backing the current process. This is the "minimal launcher" + /// that gets re-invoked (elevated) to actually run the script. + /// + string ExecutablePath { get; } + + /// + /// True if the current process is already running with administrator/root privileges. + /// + bool IsElevated { get; } +} diff --git a/LANCommander.Launcher.Services/PowerShell/IElevatedProcessLauncher.cs b/LANCommander.Launcher.Services/PowerShell/IElevatedProcessLauncher.cs new file mode 100644 index 00000000..4e91be3a --- /dev/null +++ b/LANCommander.Launcher.Services/PowerShell/IElevatedProcessLauncher.cs @@ -0,0 +1,32 @@ +using System.Threading.Tasks; + +namespace LANCommander.Launcher.Services; + +/// +/// Describes how to re-launch the launcher as a minimal, elevated process that runs a single script +/// with the supplied runtime parameters and then exits. +/// +public class ElevatedProcessRequest +{ + /// The launcher executable to invoke elevated. + public required string FileName { get; init; } + + /// The formatted command line (RunScript verb + options) passed to the elevated process. + public required string Arguments { get; init; } + + /// The working directory the elevated script should run in. + public string? WorkingDirectory { get; init; } +} + +/// +/// Launches an elevated process and waits for it to finish. Abstracted so the interceptor's +/// wait-for-completion behavior can be tested without spawning a real UAC-elevated process. +/// +public interface IElevatedProcessLauncher +{ + /// + /// Starts the elevated process described by and completes only once + /// that process has exited. + /// + Task LaunchAndWaitAsync(ElevatedProcessRequest request); +} diff --git a/LANCommander.Launcher.Services/ToolService.cs b/LANCommander.Launcher.Services/ToolService.cs index c3057a4e..62fc8330 100644 --- a/LANCommander.Launcher.Services/ToolService.cs +++ b/LANCommander.Launcher.Services/ToolService.cs @@ -1,5 +1,6 @@ -using LANCommander.Launcher.Data; +using LANCommander.Launcher.Data; using LANCommander.Launcher.Data.Models; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace LANCommander.Launcher.Services @@ -8,5 +9,75 @@ namespace LANCommander.Launcher.Services ILogger logger, DatabaseContext dbContext) : BaseDatabaseService(dbContext, logger) { + /// + /// Returns the install-state join rows for every tool currently installed for the given game. + /// The related is eagerly loaded. + /// + public async Task> GetInstalledToolsForGameAsync(Guid gameId) + { + return await Context.Set() + .Include(gt => gt.Tool) + .Where(gt => gt.GameId == gameId && gt.Installed) + .ToListAsync(); + } + + /// + /// Returns the install-state join rows for every game the given tool is installed for. + /// + public async Task> GetInstalledGameToolsAsync(Guid toolId) + { + return await Context.Set() + .Where(gt => gt.ToolId == toolId && gt.Installed) + .ToListAsync(); + } + + public async Task IsToolInstalledForGameAsync(Guid gameId, Guid toolId) + { + return await Context.Set() + .AnyAsync(gt => gt.GameId == gameId && gt.ToolId == toolId && gt.Installed); + } + + /// + /// Records that a tool has been installed for a specific game. Creates the join row if the + /// game/tool association does not yet exist locally. + /// + public async Task SetToolInstalledAsync(Guid gameId, Guid toolId, string installDirectory, string version) + { + var gameTool = await Context.Set() + .FirstOrDefaultAsync(gt => gt.GameId == gameId && gt.ToolId == toolId); + + if (gameTool == null) + { + gameTool = new GameTool { GameId = gameId, ToolId = toolId }; + await Context.Set().AddAsync(gameTool); + } + + gameTool.Installed = true; + gameTool.InstallDirectory = installDirectory; + gameTool.InstalledVersion = version; + gameTool.InstalledOn ??= DateTime.Now; + + await Context.SaveChangesAsync(); + } + + /// + /// Clears the install state for a tool on a specific game without removing the game/tool + /// association. Other games keep their own install state. + /// + public async Task SetToolUninstalledAsync(Guid gameId, Guid toolId) + { + var gameTool = await Context.Set() + .FirstOrDefaultAsync(gt => gt.GameId == gameId && gt.ToolId == toolId); + + if (gameTool == null) + return; + + gameTool.Installed = false; + gameTool.InstallDirectory = null; + gameTool.InstalledVersion = null; + gameTool.InstalledOn = null; + + await Context.SaveChangesAsync(); + } } } diff --git a/LANCommander.Launcher.Tests/Baselines/GameActionBar_Interaction_BecomesRunning.png b/LANCommander.Launcher.Tests/Baselines/GameActionBar_Interaction_BecomesRunning.png index 3cd7c180..eb9c8dc4 100644 Binary files a/LANCommander.Launcher.Tests/Baselines/GameActionBar_Interaction_BecomesRunning.png and b/LANCommander.Launcher.Tests/Baselines/GameActionBar_Interaction_BecomesRunning.png differ diff --git a/LANCommander.Launcher.Tests/Baselines/GameActionBar_Running.png b/LANCommander.Launcher.Tests/Baselines/GameActionBar_Running.png index 3cd7c180..eb9c8dc4 100644 Binary files a/LANCommander.Launcher.Tests/Baselines/GameActionBar_Running.png and b/LANCommander.Launcher.Tests/Baselines/GameActionBar_Running.png differ diff --git a/LANCommander.Launcher.Tests/Baselines/GameDetailView_Installed.png b/LANCommander.Launcher.Tests/Baselines/GameDetailView_Installed.png index 709847ae..0e2c5f4f 100644 Binary files a/LANCommander.Launcher.Tests/Baselines/GameDetailView_Installed.png and b/LANCommander.Launcher.Tests/Baselines/GameDetailView_Installed.png differ diff --git a/LANCommander.Launcher.Tests/Baselines/GameDetailView_NotInstalled.png b/LANCommander.Launcher.Tests/Baselines/GameDetailView_NotInstalled.png index 2ea79004..422b8890 100644 Binary files a/LANCommander.Launcher.Tests/Baselines/GameDetailView_NotInstalled.png and b/LANCommander.Launcher.Tests/Baselines/GameDetailView_NotInstalled.png differ diff --git a/LANCommander.Launcher.Tests/Baselines/GamesListView.png b/LANCommander.Launcher.Tests/Baselines/GamesListView.png index fc7f6d10..a60df664 100644 Binary files a/LANCommander.Launcher.Tests/Baselines/GamesListView.png and b/LANCommander.Launcher.Tests/Baselines/GamesListView.png differ diff --git a/LANCommander.Launcher.Tests/Baselines/GamesListView_Empty.png b/LANCommander.Launcher.Tests/Baselines/GamesListView_Empty.png index 5d81a5d7..e413e607 100644 Binary files a/LANCommander.Launcher.Tests/Baselines/GamesListView_Empty.png and b/LANCommander.Launcher.Tests/Baselines/GamesListView_Empty.png differ diff --git a/LANCommander.Launcher.Tests/Baselines/GamesListView_Interaction_EmptyAfterClear.png b/LANCommander.Launcher.Tests/Baselines/GamesListView_Interaction_EmptyAfterClear.png index 5d81a5d7..e413e607 100644 Binary files a/LANCommander.Launcher.Tests/Baselines/GamesListView_Interaction_EmptyAfterClear.png and b/LANCommander.Launcher.Tests/Baselines/GamesListView_Interaction_EmptyAfterClear.png differ diff --git a/LANCommander.Launcher.Tests/Baselines/GamesListView_Interaction_FilteredToOne.png b/LANCommander.Launcher.Tests/Baselines/GamesListView_Interaction_FilteredToOne.png index eb0cff34..58a76f0e 100644 Binary files a/LANCommander.Launcher.Tests/Baselines/GamesListView_Interaction_FilteredToOne.png and b/LANCommander.Launcher.Tests/Baselines/GamesListView_Interaction_FilteredToOne.png differ diff --git a/LANCommander.Launcher.Tests/Baselines/LoginView.png b/LANCommander.Launcher.Tests/Baselines/LoginView.png index 2390009d..8cf561a8 100644 Binary files a/LANCommander.Launcher.Tests/Baselines/LoginView.png and b/LANCommander.Launcher.Tests/Baselines/LoginView.png differ diff --git a/LANCommander.Launcher.Tests/Baselines/ServerSelectionView.png b/LANCommander.Launcher.Tests/Baselines/ServerSelectionView.png index cda01065..a63145a5 100644 Binary files a/LANCommander.Launcher.Tests/Baselines/ServerSelectionView.png and b/LANCommander.Launcher.Tests/Baselines/ServerSelectionView.png differ diff --git a/LANCommander.Launcher.Tests/Baselines/SplashView.png b/LANCommander.Launcher.Tests/Baselines/SplashView.png index 174296e1..a2a12714 100644 Binary files a/LANCommander.Launcher.Tests/Baselines/SplashView.png and b/LANCommander.Launcher.Tests/Baselines/SplashView.png differ diff --git a/LANCommander.Launcher.Tests/TestApp.axaml b/LANCommander.Launcher.Tests/TestApp.axaml index 8cd4a9d6..7ba5b01c 100644 --- a/LANCommander.Launcher.Tests/TestApp.axaml +++ b/LANCommander.Launcher.Tests/TestApp.axaml @@ -1,8 +1,16 @@ + + + + + diff --git a/LANCommander.Launcher.Tests/Tests/ElevatedScriptInterceptorTests.cs b/LANCommander.Launcher.Tests/Tests/ElevatedScriptInterceptorTests.cs new file mode 100644 index 00000000..b676d143 --- /dev/null +++ b/LANCommander.Launcher.Tests/Tests/ElevatedScriptInterceptorTests.cs @@ -0,0 +1,229 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using LANCommander.Launcher.Services; +using LANCommander.SDK.Abstractions; +using LANCommander.SDK.Enums; +using LANCommander.SDK.PowerShell; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Xunit; +using SdkSettings = LANCommander.SDK.Models.Settings; +using ManifestGame = LANCommander.SDK.Models.Manifest.Game; + +namespace LANCommander.Launcher.Tests.Tests; + +/// +/// Verifies the admin-elevation path for launcher scripts. When a script is flagged +/// #Requires -RunAsAdministrator and the launcher is not already elevated, the interceptor +/// must re-launch the launcher as a minimal elevated process, pass it every runtime parameter the +/// script needs, wait until that process exits, and only then report the script as handled. In every +/// other case (no admin required, already elevated, or a failure) it must fall through so the script +/// runs in-process. +/// +public class ElevatedScriptInterceptorTests +{ + private static PowerShellScript CreateScript(ScriptType type) + { + var services = new ServiceCollection(); + + services.AddLogging(); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + + return new PowerShellScript(provider, type, Options.Create(new SdkSettings())); + } + + [Fact] + public async Task NonAdminScript_ReturnsFalse_AndDoesNotLaunchElevatedProcess() + { + var processInfo = new FakeCurrentProcessInfo { IsElevated = false }; + var launcher = new RecordingElevatedProcessLauncher(); + var interceptor = new ElevatedScriptInterceptor(processInfo, launcher); + + var script = CreateScript(ScriptType.Install); + script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() }); + script.AddVariable("InstallDirectory", "InstallDir"); + // Note: not calling AsAdmin() — script does not require elevation. + + var handled = await interceptor.ExecuteAsync(script); + + Assert.False(handled); + Assert.Equal(0, launcher.LaunchCount); + } + + [Fact] + public async Task AdminScript_WhenAlreadyElevated_ReturnsFalse_AndDoesNotLaunchElevatedProcess() + { + var processInfo = new FakeCurrentProcessInfo { IsElevated = true }; + var launcher = new RecordingElevatedProcessLauncher(); + var interceptor = new ElevatedScriptInterceptor(processInfo, launcher); + + var script = CreateScript(ScriptType.Install).AsAdmin(); + script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() }); + script.AddVariable("InstallDirectory", "InstallDir"); + + var handled = await interceptor.ExecuteAsync(script); + + Assert.False(handled); + Assert.Equal(0, launcher.LaunchCount); + } + + [Fact] + public async Task AdminScript_WhenNotElevated_LaunchesMinimalLauncherWithRunAsParametersAndWaits() + { + var gameId = Guid.NewGuid(); + var processInfo = new FakeCurrentProcessInfo + { + IsElevated = false, + ExecutablePath = @"C:\LANCommander\LANCommander.Launcher.exe", + }; + var launcher = new RecordingElevatedProcessLauncher(); + var interceptor = new ElevatedScriptInterceptor(processInfo, launcher); + + var script = CreateScript(ScriptType.Install).AsAdmin().UseWorkingDirectory("WorkDir"); + script.AddVariable("GameManifest", new ManifestGame { Id = gameId }); + script.AddVariable("InstallDirectory", "InstallDir"); + + var handled = await interceptor.ExecuteAsync(script); + + Assert.True(handled); + Assert.Equal(1, launcher.LaunchCount); + + var request = Assert.Single(launcher.Requests); + + // Re-launches this same launcher executable as the elevated process. + Assert.Equal(processInfo.ExecutablePath, request.FileName); + // Preserves the working directory so the elevated script runs in the right place. + Assert.Equal("WorkDir", request.WorkingDirectory); + + // Passes the RunScript verb plus every parameter the elevated process needs to run the script. + Assert.Contains("RunScript", request.Arguments); + Assert.Contains(gameId.ToString(), request.Arguments); + Assert.Contains("InstallDir", request.Arguments); + Assert.Contains(ScriptType.Install.ToString(), request.Arguments); + + // The interceptor must not report the script handled until the elevated process has exited. + Assert.True(launcher.CompletedBeforeReturn); + } + + [Fact] + public async Task KeyChangeScript_ForwardsAllocatedKeyToElevatedProcess() + { + var processInfo = new FakeCurrentProcessInfo { IsElevated = false }; + var launcher = new RecordingElevatedProcessLauncher(); + var interceptor = new ElevatedScriptInterceptor(processInfo, launcher); + + var script = CreateScript(ScriptType.KeyChange).AsAdmin(); + script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() }); + script.AddVariable("InstallDirectory", "InstallDir"); + script.AddVariable("AllocatedKey", "KEY-12345"); + + var handled = await interceptor.ExecuteAsync(script); + + Assert.True(handled); + var request = Assert.Single(launcher.Requests); + Assert.Contains(ScriptType.KeyChange.ToString(), request.Arguments); + Assert.Contains("KEY-12345", request.Arguments); + } + + [Fact] + public async Task NameChangeScript_ForwardsOldAndNewAliasesToElevatedProcess() + { + var processInfo = new FakeCurrentProcessInfo { IsElevated = false }; + var launcher = new RecordingElevatedProcessLauncher(); + var interceptor = new ElevatedScriptInterceptor(processInfo, launcher); + + var script = CreateScript(ScriptType.NameChange).AsAdmin(); + script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() }); + script.AddVariable("InstallDirectory", "InstallDir"); + script.AddVariable("OldPlayerAlias", "OldAlias"); + script.AddVariable("NewPlayerAlias", "NewAlias"); + + var handled = await interceptor.ExecuteAsync(script); + + Assert.True(handled); + var request = Assert.Single(launcher.Requests); + Assert.Contains(ScriptType.NameChange.ToString(), request.Arguments); + Assert.Contains("OldAlias", request.Arguments); + Assert.Contains("NewAlias", request.Arguments); + } + + [Fact] + public async Task WhenElevationCheckThrows_ReturnsFalse_SoScriptRunsInProcess() + { + var processInfo = new ThrowingCurrentProcessInfo(); + var launcher = new RecordingElevatedProcessLauncher(); + var interceptor = new ElevatedScriptInterceptor(processInfo, launcher); + + var script = CreateScript(ScriptType.Install).AsAdmin(); + script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() }); + script.AddVariable("InstallDirectory", "InstallDir"); + + var handled = await interceptor.ExecuteAsync(script); + + Assert.False(handled); + Assert.Equal(0, launcher.LaunchCount); + } + + [Fact] + public async Task WhenElevatedLaunchFails_ReturnsFalse_SoScriptRunsInProcess() + { + var processInfo = new FakeCurrentProcessInfo { IsElevated = false }; + var launcher = new RecordingElevatedProcessLauncher { ThrowOnLaunch = true }; + var interceptor = new ElevatedScriptInterceptor(processInfo, launcher); + + var script = CreateScript(ScriptType.Install).AsAdmin(); + script.AddVariable("GameManifest", new ManifestGame { Id = Guid.NewGuid() }); + script.AddVariable("InstallDirectory", "InstallDir"); + + var handled = await interceptor.ExecuteAsync(script); + + Assert.False(handled); + } + + private sealed class FakeCurrentProcessInfo : ICurrentProcessInfo + { + public string ExecutablePath { get; init; } = @"C:\LANCommander\LANCommander.Launcher.exe"; + public bool IsElevated { get; init; } + } + + private sealed class ThrowingCurrentProcessInfo : ICurrentProcessInfo + { + public string ExecutablePath => throw new InvalidOperationException("path unavailable"); + public bool IsElevated => throw new InvalidOperationException("cannot determine elevation"); + } + + private sealed class RecordingElevatedProcessLauncher : IElevatedProcessLauncher + { + public List Requests { get; } = new(); + public int LaunchCount => Requests.Count; + public bool ThrowOnLaunch { get; init; } + + /// Set once the (awaited) launch has fully completed. Proves the caller waited. + public bool CompletedBeforeReturn { get; private set; } + + public async Task LaunchAndWaitAsync(ElevatedProcessRequest request) + { + Requests.Add(request); + + if (ThrowOnLaunch) + throw new InvalidOperationException("elevated launch failed"); + + // Simulate the elevated process running for a moment; if the interceptor did not await + // this, CompletedBeforeReturn would still be false when ExecuteAsync returns. + await Task.Delay(20); + + CompletedBeforeReturn = true; + } + } + + private sealed class FakeSettingsProvider : ISettingsProvider + { + public SdkSettings CurrentValue { get; } = new(); + + public void Update(Action patch) => patch(CurrentValue); + } +} diff --git a/LANCommander.Launcher.Tests/Tests/ViewLayoutTests.cs b/LANCommander.Launcher.Tests/Tests/ViewLayoutTests.cs index 1270daa6..9e6979c4 100644 --- a/LANCommander.Launcher.Tests/Tests/ViewLayoutTests.cs +++ b/LANCommander.Launcher.Tests/Tests/ViewLayoutTests.cs @@ -3,6 +3,7 @@ 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; @@ -31,12 +32,23 @@ 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: just logging — no real SDK services needed for layout-only rendering. + // Minimal: logging plus navigation — GameDetailViewModel resolves INavigationService + // in its constructor. 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/App.axaml index 080b5adb..a3733655 100644 --- a/LANCommander.Launcher/App.axaml +++ b/LANCommander.Launcher/App.axaml @@ -25,6 +25,29 @@ + + + + + + + + - - @@ -527,6 +526,17 @@ + + + + + + + + @@ -558,6 +568,28 @@ + + + + + + + + + + + + + + diff --git a/LANCommander.Launcher/ViewModels/Components/GameActionBarViewModel.cs b/LANCommander.Launcher/ViewModels/Components/GameActionBarViewModel.cs index 51738bc2..562b11df 100644 --- a/LANCommander.Launcher/ViewModels/Components/GameActionBarViewModel.cs +++ b/LANCommander.Launcher/ViewModels/Components/GameActionBarViewModel.cs @@ -31,7 +31,7 @@ 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 +public partial class GameActionBarViewModel : ViewModelBase, IDisposable { private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; @@ -88,10 +88,10 @@ public partial class GameActionBarViewModel : ViewModelBase // Stats [ObservableProperty] - private string _playTime = "None"; + private string _playTime = Localize("PlayStatNone"); [ObservableProperty] - private string _lastPlayed = "Never"; + private string _lastPlayed = Localize("LastPlayedNever"); // Status [ObservableProperty] @@ -160,7 +160,7 @@ public partial class GameActionBarViewModel : ViewModelBase /// /// Shows "Play" label when no update available and game is idle (not running/starting) /// - public bool ShowPlayLabel => !IsUpdateAvailable && !IsRunning && !IsStarting; + public bool ShowPlayLabel => !IsRunning && (!IsUpdateAvailable || IsStarting); /// /// Can install only when online and not already installed @@ -172,6 +172,12 @@ public partial class GameActionBarViewModel : ViewModelBase // 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; @@ -196,6 +202,7 @@ public partial class GameActionBarViewModel : ViewModelBase && game.InstalledVersion != game.LatestVersion; using var scope = _serviceProvider.CreateScope(); + var libraryService = scope.ServiceProvider.GetRequiredService(); var settingsProvider = scope.ServiceProvider.GetRequiredService(); @@ -212,6 +219,44 @@ public partial class GameActionBarViewModel : ViewModelBase _ = 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 /// @@ -245,8 +290,8 @@ public partial class GameActionBarViewModel : ViewModelBase IsInstalled = false; InstallDirectory = null; IsUpdateAvailable = false; - PlayTime = "None"; - LastPlayed = "Never"; + PlayTime = Localize("PlayStatNone"); + LastPlayed = Localize("LastPlayedNever"); Manuals.Clear(); HasManuals = false; } @@ -362,12 +407,31 @@ public partial class GameActionBarViewModel : ViewModelBase 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() @@ -443,35 +507,61 @@ public partial class GameActionBarViewModel : ViewModelBase .Sum(ts => ts.Ticks)); if (totalTime.TotalMinutes < 1) - PlayTime = "None"; + PlayTime = Localize("PlayStatNone"); else if (totalTime.TotalHours < 1) - PlayTime = $"{totalTime.TotalMinutes:0} minutes"; + PlayTime = Localize("PlayTimeMinutes", $"{totalTime.TotalMinutes:0}"); else - PlayTime = $"{totalTime.TotalHours:0.#} hours"; + PlayTime = Localize("PlayTimeHours", $"{totalTime.TotalHours:0.#}"); var lastSession = playSessions .OrderByDescending(ps => ps.End) .First(); - 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"); + _lastSessionEnd = lastSession.End!.Value; + UpdateLastPlayedText(); } else { - PlayTime = "None"; - LastPlayed = "Never"; + 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. @@ -851,7 +941,7 @@ public partial class GameActionBarViewModel : ViewModelBase try { var tools = await gameClient.GetToolsAsync(GameId); - availableTools = tools?.ToArray() ?? []; + availableTools = tools?.Where(t => (t.Archives?.Any() ?? false) && !t.AlwaysInstall).ToArray() ?? []; } catch (Exception ex) { @@ -971,8 +1061,7 @@ public partial class GameActionBarViewModel : ViewModelBase var dbContext = scope.ServiceProvider.GetRequiredService(); var localGame = await dbContext.Set() - .Include(g => g.DependentGames) - .Include(g => g.Tools) + .Include(g => g.GameTools) .FirstOrDefaultAsync(g => g.Id == GameId); if (localGame == null) @@ -996,24 +1085,28 @@ public partial class GameActionBarViewModel : ViewModelBase try { var tools = await gameClient.GetToolsAsync(GameId); - availableTools = tools?.ToArray() ?? []; + 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 + // 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( - (localGame.DependentGames ?? []) - .Where(a => a.Installed) - .Select(a => a.Id)); + await dbContext.Set() + .Where(g => availableAddonIds.Contains(g.Id) && g.Installed) + .Select(g => g.Id) + .ToListAsync()); - // Build set of currently installed tool IDs + // Build set of currently installed tool IDs (tracked per game) var installedToolIds = new HashSet( - (localGame.Tools ?? []) - .Where(t => t.Installed) - .Select(t => t.Id)); + (localGame.GameTools ?? []) + .Where(gt => gt.Installed) + .Select(gt => gt.ToolId)); // ── Build options VM ─────────────────────────────────────────────── var optionsVm = new InstallOptionsViewModel(); diff --git a/LANCommander.Launcher/ViewModels/Components/GameItemViewModel.cs b/LANCommander.Launcher/ViewModels/Components/GameItemViewModel.cs index 4dba6e87..003caab5 100644 --- a/LANCommander.Launcher/ViewModels/Components/GameItemViewModel.cs +++ b/LANCommander.Launcher/ViewModels/Components/GameItemViewModel.cs @@ -86,6 +86,9 @@ public partial class GameItemViewModel : ViewModelBase [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) @@ -95,6 +98,32 @@ public partial class GameItemViewModel : ViewModelBase 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; @@ -119,6 +148,7 @@ public partial class GameItemViewModel : ViewModelBase 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; diff --git a/LANCommander.Launcher/ViewModels/Components/GameMediaItemViewModel.cs b/LANCommander.Launcher/ViewModels/Components/GameMediaItemViewModel.cs index 880070af..d25fd936 100644 --- a/LANCommander.Launcher/ViewModels/Components/GameMediaItemViewModel.cs +++ b/LANCommander.Launcher/ViewModels/Components/GameMediaItemViewModel.cs @@ -8,11 +8,21 @@ public partial class GameMediaItemViewModel : ObservableObject { /// Local file path or remote URL for videos. [ObservableProperty] + [NotifyPropertyChangedFor(nameof(VideoPath))] private string _path = string.Empty; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(VideoPath))] private bool _isVideo; + /// + /// Path for the inline video player — only populated for video items. The carousel + /// template instantiates an InlineVideoPlayer for every item (even hidden ones for + /// screenshots), so binding the screenshot path here would make libvlc try to "play" + /// the image and leak a decoder per item. Null for screenshots keeps the player idle. + /// + public string? VideoPath => IsVideo ? Path : null; + [ObservableProperty] private string _mimeType = string.Empty; diff --git a/LANCommander.Launcher/ViewModels/Components/LibrarySidebarViewModel.cs b/LANCommander.Launcher/ViewModels/Components/LibrarySidebarViewModel.cs index cd272946..fa74913c 100644 --- a/LANCommander.Launcher/ViewModels/Components/LibrarySidebarViewModel.cs +++ b/LANCommander.Launcher/ViewModels/Components/LibrarySidebarViewModel.cs @@ -39,6 +39,9 @@ public partial class LibrarySidebarViewModel : ViewModelBase [ObservableProperty] private bool _isOfflineMode; + [ObservableProperty] + private bool _areUserLibrariesEnabled = true; + public event EventHandler? DepotSelected; public event EventHandler? ItemSelected; public event EventHandler? RefreshRequested; @@ -79,6 +82,19 @@ public partial class LibrarySidebarViewModel : ViewModelBase 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 ?? []) diff --git a/LANCommander.Launcher/ViewModels/DepotViewModel.cs b/LANCommander.Launcher/ViewModels/DepotViewModel.cs index c327b06c..8948620b 100644 --- a/LANCommander.Launcher/ViewModels/DepotViewModel.cs +++ b/LANCommander.Launcher/ViewModels/DepotViewModel.cs @@ -1,8 +1,8 @@ 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; @@ -21,6 +21,11 @@ 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; @@ -78,6 +83,20 @@ 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; @@ -120,25 +139,20 @@ public partial class DepotViewModel : ViewModelBase allGames.Add(dg); } - // Parallel: download covers + resolve library membership + // 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 = new HashSet(); + var librarySet = await libraryService.GetLibraryGameIdsAsync(); - await Task.Run(async () => + foreach (var game in allGames) { - foreach (var game in allGames) + if (game.Cover != null) { - if (await libraryService.IsInLibraryAsync(game.Id)) - librarySet.Add(game.Id); - - if (game.Cover != null) - { - coverCache[game.Id] = await GetOrDownloadMediaAsync(game.Cover, mediaClient); - coverMimeCache[game.Id] = game.Cover.MimeType; - } + coverCache[game.Id] = MediaUrl(game.Cover, mediaClient); + coverMimeCache[game.Id] = game.Cover.MimeType; } - }); + } // ── Popular games: newest 10 (by CreatedOn desc), fetch full data for hero+logo ── @@ -406,9 +420,9 @@ public partial class DepotViewModel : ViewModelBase var inLibrary = librarySet.Contains(game.Id); var coverMedia = game.Media?.FirstOrDefault(m => m.Type == MediaType.Cover); - var coverPath = await GetOrDownloadMediaAsync(coverMedia, 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 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 vm = new GameItemViewModel(depotGame, coverPath, coverMedia?.MimeType, inLibrary); @@ -429,8 +443,8 @@ public partial class DepotViewModel : ViewModelBase try { var game = await gameClient.GetAsync(depotGame.Id); - - return await GetOrDownloadMediaAsync(game?.Media?.FirstOrDefault(m => m.Type == MediaType.Background), mediaClient); + + return MediaUrl(game?.Media?.FirstOrDefault(m => m.Type == MediaType.Background), mediaClient); } catch { @@ -438,24 +452,17 @@ public partial class DepotViewModel : ViewModelBase } } - private static async Task GetOrDownloadMediaAsync(Media? media, MediaClient mediaClient) + // 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) { 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/ViewModels/DownloadQueueViewModel.cs b/LANCommander.Launcher/ViewModels/DownloadQueueViewModel.cs index fc4b25ba..8a2e0954 100644 --- a/LANCommander.Launcher/ViewModels/DownloadQueueViewModel.cs +++ b/LANCommander.Launcher/ViewModels/DownloadQueueViewModel.cs @@ -85,6 +85,8 @@ public partial class DownloadQueueViewModel : ViewModelBase public event EventHandler? InstallCompleted; + public event EventHandler? ToolInstallCompleted; + public DownloadQueueViewModel(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; @@ -104,6 +106,8 @@ public partial class DownloadQueueViewModel : ViewModelBase _installService.OnProgress += OnProgress; _installService.OnTaskProgressUpdate += OnTaskProgressUpdate; _installService.OnInstallComplete += OnInstallComplete; + _installService.OnToolInstallComplete += OnToolInstallComplete; + _installService.OnInstallQueueComplete += OnInstallQueueComplete; _installService.OnInstallFail += OnInstallFail; RefreshQueue(); @@ -148,10 +152,23 @@ public partial class DownloadQueueViewModel : ViewModelBase private Task OnProgress(InstallProgress progress) { - _taskbarProgressService.SetProgress(progress.Progress); - Dispatcher.UIThread.Post(() => { + var item = QueueItems.FirstOrDefault(i => i.Id == progress.Game?.Id); + + // Progress can arrive out-of-band when nothing is queued (e.g. a game-launch + // sub-operation). Treat the event as a real install only if it maps to a queue + // item, or if some item is already actively installing (covers addon/expansion + // sub-installs, whose progress carries the addon — not the base queue item). + // Otherwise ignore it so it can't drive the footer or taskbar. + if (item == null && !QueueItems.Any(i => i.IsActive)) + { + _taskbarProgressService.ClearProgress(); + return; + } + + _taskbarProgressService.Report(progress); + CurrentStatus = GetDisplayName(progress.Status); CurrentProgress = progress.Progress; CurrentTransferSpeed = progress.TransferSpeed; @@ -159,7 +176,7 @@ public partial class DownloadQueueViewModel : ViewModelBase // Format progress text var bytesDownloaded = ByteSize.FromBytes(progress.BytesTransferred); var totalBytes = ByteSize.FromBytes(progress.TotalBytes); - + CurrentProgressText = $"{bytesDownloaded} / {totalBytes} ({progress.Progress:P0})"; // Format transfer speed @@ -167,24 +184,31 @@ public partial class DownloadQueueViewModel : ViewModelBase // 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"; + + string remaining; + + if (ts.TotalHours >= 1) + remaining = $"{(int)ts.TotalHours}h {ts.Minutes}m"; + else if (ts.TotalMinutes >= 1) + remaining = $"{ts.Minutes}m {ts.Seconds}s"; + else + remaining = $"{ts.Seconds}s"; + + TimeRemainingText = Localize("TimeRemaining", remaining); } else { TimeRemainingText = string.Empty; } - // Update the matching queue item - var item = QueueItems.FirstOrDefault(i => i.Id == progress.Game?.Id); + // Update the matching queue item. May be null for a sub-install (e.g. an addon) + // whose progress carries the addon rather than the active base queue item; the + // footer/taskbar above still reflect it. if (item != null) { item.UpdateProgress(progress.Status, progress.Progress, progress.TransferSpeed, progress.BytesTransferred, progress.TotalBytes); @@ -201,12 +225,38 @@ public partial class DownloadQueueViewModel : ViewModelBase return Task.CompletedTask; } - private async Task OnInstallComplete(Data.Models.Game game) + private Task OnInstallComplete(Data.Models.Game game) { _logger.LogInformation("Install complete for game {GameTitle}", game.Title); _taskbarProgressService.ClearProgress(); + Dispatcher.UIThread.Post(() => + { + RefreshQueue(); + InstallCompleted?.Invoke(this, game.Id); + }); + + return Task.CompletedTask; + } + + private Task OnToolInstallComplete(Data.Models.Game game) + { + _logger.LogInformation("Tool install complete for game {GameTitle}", game.Title); + + Dispatcher.UIThread.Post(() => + { + RefreshQueue(); + ToolInstallCompleted?.Invoke(this, game.Id); + }); + + return Task.CompletedTask; + } + + private async Task OnInstallQueueComplete(Data.Models.Game game) + { + _logger.LogInformation("Install batch complete for game {GameTitle}", game.Title); + // Resolve icon and grid art paths for the notification string? iconPath = null; string? gridPath = null; @@ -216,12 +266,12 @@ public partial class DownloadQueueViewModel : ViewModelBase 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); } @@ -231,19 +281,13 @@ public partial class DownloadQueueViewModel : ViewModelBase } _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(); + _taskbarProgressService.SetError(); _notificationService.NotifyInstallFailed(game.Title ?? "Game", game.Id); Dispatcher.UIThread.Post(RefreshQueue); @@ -608,8 +652,8 @@ public partial class InstallQueueItemViewModel : ViewModelBase public bool HasSpeedData => true; public string SpeedChartLabel => IsActive - ? Localization.Localize("TransferSpeed") - : Localization.Localize("TransferSpeedHistory"); + ? Localize("TransferSpeed") + : Localize("TransferSpeedHistory"); public InstallQueueItemViewModel() { @@ -700,25 +744,25 @@ public partial class InstallQueueItemViewModel : ViewModelBase if (Status == InstallStatus.Complete) { - ProgressText = "Complete"; + ProgressText = Localize("InstallStatusComplete"); SpeedText = string.Empty; PercentText = string.Empty; - StatusText = "Complete"; + StatusText = Localize("InstallStatusComplete"); CompletedOnText = item.CompletedOn?.ToString("g"); } else if (Status == InstallStatus.Failed) { - ProgressText = "Failed"; + ProgressText = Localize("InstallStatusFailed"); SpeedText = string.Empty; PercentText = string.Empty; - StatusText = "Failed"; + StatusText = Localize("InstallStatusFailed"); } else if (Status == InstallStatus.Queued) { - ProgressText = "Queued"; + ProgressText = Localize("InstallStatusQueued"); SpeedText = string.Empty; PercentText = string.Empty; - StatusText = "Queued"; + StatusText = Localize("InstallStatusQueued"); } else { @@ -741,6 +785,7 @@ public partial class InstallQueueItemViewModel : ViewModelBase { var member = typeof(InstallStatus).GetField(status.ToString()); var display = member?.GetCustomAttribute(); + return display?.Name ?? status.ToString(); } } diff --git a/LANCommander.Launcher/ViewModels/GameDetailViewModel.cs b/LANCommander.Launcher/ViewModels/GameDetailViewModel.cs index d022d7a9..2201d740 100644 --- a/LANCommander.Launcher/ViewModels/GameDetailViewModel.cs +++ b/LANCommander.Launcher/ViewModels/GameDetailViewModel.cs @@ -200,150 +200,6 @@ public partial class GameDetailViewModel : ViewModelBase 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); - CoverMimeType = game.Media?.FirstOrDefault(m => m.Type == MediaType.Cover)?.MimeType; - 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) - // Collect paths first, add skeletons for screenshots, then lazy-load bitmaps off the UI thread - MediaItems.Clear(); - TagsExpanded = false; - - var mediaEntries = new List<(string path, int index)>(); - - 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) - continue; - - if (m.Type == MediaType.Video) - { - MediaItems.Add(new GameMediaItemViewModel - { - Path = path, - IsVideo = true, - MimeType = string.Empty - }); - } - else - { - var index = MediaItems.Count; - MediaItems.Add(new GameMediaItemViewModel { IsSkeleton = true }); - mediaEntries.Add((path, index)); - } - } - } - OnPropertyChanged(nameof(HasMedia)); - - // Tools - Tools.Clear(); - if (game.Tools != null) - { - foreach (var tool in game.Tools) - Tools.Add(new ToolItemViewModel(tool)); - } - OnPropertyChanged(nameof(HasTools)); - - // Load action bar state - await ActionBar.LoadFromLocalGameAsync(game); - - // Load screenshot bitmaps on background thread, replacing skeletons as they complete - if (mediaEntries.Count > 0) - IsLoadingMedia = true; - - foreach (var entry in mediaEntries) - { - try - { - var bitmap = await Task.Run(() => new Bitmap(entry.path)); - - MediaItems[entry.index] = new GameMediaItemViewModel - { - Path = entry.path, - IsVideo = false, - MimeType = string.Empty, - ImageSource = bitmap - }; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "Failed to load local screenshot {Path}", entry.path); - } - } - - // Remove any skeletons that failed to load - for (var i = MediaItems.Count - 1; i >= 0; i--) - { - if (MediaItems[i].IsSkeleton) - MediaItems.RemoveAt(i); - } - - OnPropertyChanged(nameof(HasMedia)); - - if (mediaEntries.Count > 0) - IsLoadingMedia = false; - } - /// /// Load game from server API (SDK.Models.Game) /// Used when selecting from the depot/all games list. @@ -443,33 +299,36 @@ public partial class GameDetailViewModel : ViewModelBase // Load action bar state await ActionBar.LoadFromSdkGameAsync(game); - // Load essential media (cover, logo, background, icon) — needed for page layout 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 = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Cover, mediaClient); + CoverPath = ResolveEssentialMediaPath(game.Media, MediaType.Cover, mediaClient); CoverMimeType = game.Media.FirstOrDefault(m => m.Type == MediaType.Cover)?.MimeType; - LogoPath = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Logo, mediaClient); - BackgroundPath = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Background, mediaClient); - IconPath = await GetOrDownloadMediaPathAsync(game.Media, MediaType.Icon, mediaClient); + 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); } - - // Load screenshots/videos in the background — don't block navigation - _ = LoadCarouselMediaAsync(game); } } /// /// Loads screenshots and videos into the media carousel in the background. - /// Replaces skeleton placeholders with real items as they load. + /// 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) { @@ -488,47 +347,32 @@ public partial class GameDetailViewModel : ViewModelBase 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]; - try + + if (media.Type == MediaType.Video) { - var item = new GameMediaItemViewModel + // Videos only need a stream URL — set them immediately so they don't + // wait behind screenshot downloads. + ReplaceMediaItem(index, new GameMediaItemViewModel { - IsVideo = media.Type == MediaType.Video, - MimeType = media.MimeType ?? string.Empty - }; - - if (media.Type == MediaType.Video) - item.Path = mediaClient.GetAbsoluteStreamUrl(media); - else - { - var localPath = mediaClient.GetLocalPath(media); - - if (!File.Exists(localPath)) - { - var fileInfo = await mediaClient.DownloadAsync(media, localPath); - localPath = fileInfo.FullName; - } - - item.Path = localPath; - item.ImageSource = await Task.Run(() => new Bitmap(localPath)); - } - - // Replace skeleton at the same position, or append if index is out of range - if (i < MediaItems.Count && MediaItems[i].IsSkeleton) - MediaItems[i] = item; - else - MediaItems.Add(item); - - OnPropertyChanged(nameof(HasMedia)); + IsVideo = true, + MimeType = media.MimeType ?? string.Empty, + Path = mediaClient.GetAbsoluteStreamUrl(media) + }); } - catch (Exception ex) + else { - _logger.LogError(ex, "Failed to load media {MediaId} from server", media.Id); + 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--) { @@ -548,6 +392,60 @@ public partial class GameDetailViewModel : ViewModelBase } } + /// + /// 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); @@ -575,48 +473,11 @@ public partial class GameDetailViewModel : ViewModelBase return typeLabel; } - 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; - } + // 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) { @@ -637,8 +498,8 @@ public partial class GameDetailViewModel : ViewModelBase { foreach (var tool in tools) { - var localTool = await toolService.GetAsync(tool.Id); - Tools.Add(new ToolItemViewModel(tool, localTool)); + var isInstalled = await toolService.IsToolInstalledForGameAsync(game.Id, tool.Id); + Tools.Add(new ToolItemViewModel(tool, isInstalled)); } } } @@ -673,17 +534,10 @@ public partial class ToolItemViewModel : ViewModelBase public string Name { get; } public bool IsInstalled { get; } - public ToolItemViewModel(SDK.Models.Tool tool, Data.Models.Tool? localTool) + public ToolItemViewModel(SDK.Models.Tool tool, bool isInstalled) { Id = tool.Id; Name = tool.Name ?? "Unknown Tool"; - IsInstalled = localTool?.Installed ?? false; - } - - public ToolItemViewModel(Data.Models.Tool tool) - { - Id = tool.Id; - Name = tool.Name ?? "Unknown Tool"; - IsInstalled = tool.Installed; + IsInstalled = isInstalled; } } diff --git a/LANCommander.Launcher/ViewModels/GamesCollectionViewModel.cs b/LANCommander.Launcher/ViewModels/GamesCollectionViewModel.cs index 9d811e0d..91380bd8 100644 --- a/LANCommander.Launcher/ViewModels/GamesCollectionViewModel.cs +++ b/LANCommander.Launcher/ViewModels/GamesCollectionViewModel.cs @@ -10,6 +10,7 @@ using LANCommander.Launcher.ViewModels.Components; using LANCommander.Launcher.Settings.Enums; using LANCommander.SDK.Models; using LANCommander.SDK.Enums; +using LANCommander.SDK.Extensions; namespace LANCommander.Launcher.ViewModels; @@ -233,6 +234,8 @@ 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) || diff --git a/LANCommander.Launcher/ViewModels/LibraryViewModel.cs b/LANCommander.Launcher/ViewModels/LibraryViewModel.cs index 22276b8c..0afb4937 100644 --- a/LANCommander.Launcher/ViewModels/LibraryViewModel.cs +++ b/LANCommander.Launcher/ViewModels/LibraryViewModel.cs @@ -67,9 +67,16 @@ public partial class LibraryViewModel : GamesCollectionViewModel var mediaClient = scope.ServiceProvider.GetRequiredService(); var dbContext = scope.ServiceProvider.GetRequiredService(); + if (!IsOfflineMode) + { + var moduleClient = scope.ServiceProvider.GetRequiredService(); + await moduleClient.SyncAsync(); + } + var items = await libraryService.GetItemsAsync(); var results = new List(); var gameModels = new List(); + var iconPaths = new Dictionary(); foreach (var item in items ?? []) { @@ -85,6 +92,7 @@ public partial class LibraryViewModel : GamesCollectionViewModel coverPath = mediaService.GetImagePath(coverMedia); var iconPath = await GetOrDownloadIconPathAsync(game, mediaService, mediaClient); + iconPaths[game.Id] = iconPath; var vm = new GameItemViewModel(game, coverPath, coverMedia?.MimeType, inLibrary: true, showInLibraryBadge: false); vm.IconPath = iconPath; @@ -117,7 +125,9 @@ public partial class LibraryViewModel : GamesCollectionViewModel if (coverMedia != null && mediaService.FileExists(coverMedia)) coverPath = mediaService.GetImagePath(coverMedia); - recentItems.Add(new GameItemViewModel(game, coverPath, coverMedia?.MimeType, inLibrary: true, showInLibraryBadge: false)); + var recentVm = new GameItemViewModel(game, coverPath, coverMedia?.MimeType, inLibrary: true, showInLibraryBadge: false); + recentVm.IconPath = iconPaths.GetValueOrDefault(game.Id); + recentItems.Add(recentVm); } // Collections: distinct collections from library games @@ -133,7 +143,9 @@ public partial class LibraryViewModel : GamesCollectionViewModel // Use cover of first game in collection as background string? bgPath = null; var representativeGame = group.First().Game; + var bgMedia = representativeGame.Media?.FirstOrDefault(m => m.Type == MediaType.Cover); + if (bgMedia != null && mediaService.FileExists(bgMedia)) bgPath = mediaService.GetImagePath(bgMedia); @@ -155,6 +167,7 @@ public partial class LibraryViewModel : GamesCollectionViewModel AvailableTags.Clear(); AvailableDevelopers.Clear(); AvailablePublishers.Clear(); + foreach (var vm in collected) _allGames.Add(vm); @@ -185,6 +198,75 @@ public partial class LibraryViewModel : GamesCollectionViewModel } } + /// + /// Fills the library instantly from the server's scoped library endpoint, streaming covers and + /// icons that aren't cached locally yet. Only adds games not already present from the local + /// database load, so returning users keep their authoritative (install-aware) entries and + /// first-run/newly-added games appear without waiting on the slow import. Once the import + /// completes, the local-DB reload replaces these with fully-populated entries. + /// + public async Task LoadLibraryFromServerAsync() + { + if (IsOfflineMode) + return; + + try + { + var existingIds = _allGames.Select(g => g.Id).ToHashSet(); + + var newItems = await Task.Run(async () => + { + using var scope = _serviceProvider.CreateScope(); + var libraryClient = scope.ServiceProvider.GetRequiredService(); + var mediaClient = scope.ServiceProvider.GetRequiredService(); + + var games = await libraryClient.GetGamesAsync(); + var results = new List(); + + foreach (var game in games) + { + if (existingIds.Contains(game.Id)) + continue; + + var coverMedia = game.Media?.FirstOrDefault(m => m.Type == MediaType.Cover); + var iconMedia = game.Media?.FirstOrDefault(m => m.Type == MediaType.Icon); + + var vm = new GameItemViewModel( + game, + MediaSourceResolver.Resolve(coverMedia, mediaClient), + coverMedia?.MimeType, + inLibrary: true, + showInLibraryBadge: false); + + vm.IconPath = MediaSourceResolver.Resolve(iconMedia, mediaClient); + + results.Add(vm); + } + + return results; + }); + + if (newItems.Count == 0) + return; + + foreach (var vm in newItems) + _allGames.Add(vm); + + PopulateGenres(); + PopulateCollections(); + PopulateTags(); + PopulateDevelopers(); + PopulatePublishers(); + ApplyFilters(); + + _logger.LogInformation("Added {Count} library games from server instant pass", newItems.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to load library from server"); + } + } + [RelayCommand] private void FilterByCollection(string? collectionName) { @@ -202,7 +284,8 @@ public partial class LibraryViewModel : GamesCollectionViewModel protected override async Task ViewGameDetailsAsync(GameItemViewModel? gameItem) { - if (gameItem == null) return; + if (gameItem == null) + return; try { @@ -248,7 +331,9 @@ public partial class LibraryViewModel : GamesCollectionViewModel MediaClient mediaClient) { var iconMedia = game.Media?.FirstOrDefault(m => m.Type == MediaType.Icon); - if (iconMedia == null) return null; + + if (iconMedia == null) + return null; if (mediaService.FileExists(iconMedia)) return mediaService.GetImagePath(iconMedia); diff --git a/LANCommander.Launcher/ViewModels/LoginViewModel.cs b/LANCommander.Launcher/ViewModels/LoginViewModel.cs index 2cba5f67..10f3feb1 100644 --- a/LANCommander.Launcher/ViewModels/LoginViewModel.cs +++ b/LANCommander.Launcher/ViewModels/LoginViewModel.cs @@ -32,12 +32,18 @@ public partial class LoginViewModel : ViewModelBase private string _passwordConfirmation = string.Empty; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowPasswordConfirmation))] private bool _isRegistering; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowRegisterToggle))] + private bool _allowRegistration = true; + [ObservableProperty] private string _statusMessage = string.Empty; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowPrimaryButtons))] private bool _isLoading; [ObservableProperty] @@ -49,8 +55,34 @@ public partial class LoginViewModel : ViewModelBase [ObservableProperty] private bool _isServerOffline; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(ShowCredentialFields))] + [NotifyPropertyChangedFor(nameof(ShowPasswordConfirmation))] + [NotifyPropertyChangedFor(nameof(ShowPrimaryButtons))] + [NotifyPropertyChangedFor(nameof(ShowRegisterToggle))] + [NotifyPropertyChangedFor(nameof(ShowProviderButtons))] + [NotifyPropertyChangedFor(nameof(ShowProviderSeparator))] + private bool _autoRedirectToProvider; + public ObservableCollection AuthenticationProviders { get; } = new(); + // When auto-redirect is enabled, the username/password login path is hidden entirely. + public bool ShowCredentialFields => !AutoRedirectToProvider; + + public bool ShowPasswordConfirmation => IsRegistering && !AutoRedirectToProvider; + + public bool ShowPrimaryButtons => !IsLoading && !AutoRedirectToProvider; + + public bool ShowRegisterToggle => AllowRegistration && !AutoRedirectToProvider; + + // Hide provider buttons only in the case the launcher auto-redirects (auto-redirect on + exactly one provider). + public bool ShowProviderButtons => AutoRedirectToProvider + ? AuthenticationProviders.Count > 1 + : AuthenticationProviders.Count >= 1; + + // The "or" separator only makes sense when both credential fields and provider buttons are shown. + public bool ShowProviderSeparator => !AutoRedirectToProvider && ShowProviderButtons; + public event EventHandler? LoginSucceeded; public event EventHandler? ChangeServerRequested; @@ -65,6 +97,12 @@ public partial class LoginViewModel : ViewModelBase _authenticationClient = authenticationClient; _settingsProvider = settingsProvider; + AuthenticationProviders.CollectionChanged += (_, _) => + { + OnPropertyChanged(nameof(ShowProviderButtons)); + OnPropertyChanged(nameof(ShowProviderSeparator)); + }; + ServerAddress = _connectionClient.GetServerAddress()?.ToString() ?? "Not connected"; } @@ -86,6 +124,23 @@ public partial class LoginViewModel : ViewModelBase { // External providers unavailable - username/password login still works } + + AllowRegistration = await _authenticationClient.GetRegistrationAllowedAsync(); + + if (!AllowRegistration && IsRegistering) + IsRegistering = false; + + AutoRedirectToProvider = await _authenticationClient.GetAutoRedirectToProviderAsync(); + } + + public async Task TryAutoRedirectToProviderAsync() + { + if (IsServerOffline || !AutoRedirectToProvider || IsLoading) + return; + + // Mirror server behavior: only auto-challenge when exactly one provider exists. + if (AuthenticationProviders.Count == 1) + await LoginWithProviderAsync(AuthenticationProviders[0]); } [RelayCommand] diff --git a/LANCommander.Launcher/ViewModels/MainWindowViewModel.cs b/LANCommander.Launcher/ViewModels/MainWindowViewModel.cs index b729a9cc..ae7feae6 100644 --- a/LANCommander.Launcher/ViewModels/MainWindowViewModel.cs +++ b/LANCommander.Launcher/ViewModels/MainWindowViewModel.cs @@ -181,8 +181,12 @@ public partial class MainWindowViewModel : ViewModelBase if (serverOnline) await LoginViewModel.LoadAuthenticationProvidersAsync(); - + CurrentView = LoginViewModel; + + if (serverOnline) + await LoginViewModel.TryAutoRedirectToProviderAsync(); + return; } @@ -196,6 +200,7 @@ public partial class MainWindowViewModel : ViewModelBase LoginViewModel.IsServerOffline = false; await LoginViewModel.LoadAuthenticationProvidersAsync(); CurrentView = LoginViewModel; + await LoginViewModel.TryAutoRedirectToProviderAsync(); } private async void OnLoginSucceeded(object? sender, EventArgs e) diff --git a/LANCommander.Launcher/ViewModels/MediaSourceResolver.cs b/LANCommander.Launcher/ViewModels/MediaSourceResolver.cs new file mode 100644 index 00000000..7bbcc774 --- /dev/null +++ b/LANCommander.Launcher/ViewModels/MediaSourceResolver.cs @@ -0,0 +1,38 @@ +using System; +using System.IO; +using LANCommander.SDK.Services; + +namespace LANCommander.Launcher.ViewModels; + +/// +/// Resolves an image source for a piece of media, preferring the locally cached file written by +/// the background import and falling back to streaming it straight from the server when it hasn't +/// been cached yet. This lets the library and detail views render instantly while the import is +/// still running, then transparently switch to the on-disk copy once it lands. +/// +public static class MediaSourceResolver +{ + /// + /// Returns a local file path when the media is already cached, otherwise a server URL: + /// the range-capable stream endpoint for video, or the resized thumbnail for images. + /// Returns null when there is no media. + /// + public static string? Resolve(SDK.Models.Media? media, MediaClient mediaClient) + { + if (media == null) + return null; + + var localPath = mediaClient.GetLocalPath(media); + + if (File.Exists(localPath)) + return localPath; + + if (mediaClient.IsOfflineMode()) + return null; + + if (media.MimeType?.StartsWith("video/", StringComparison.OrdinalIgnoreCase) == true) + return mediaClient.GetAbsoluteStreamUrl(media); + + return mediaClient.GetAbsoluteThumbnailUrl(media); + } +} diff --git a/LANCommander.Launcher/ViewModels/SettingsViewModel.cs b/LANCommander.Launcher/ViewModels/SettingsViewModel.cs index a5b51939..d4b54d6e 100644 --- a/LANCommander.Launcher/ViewModels/SettingsViewModel.cs +++ b/LANCommander.Launcher/ViewModels/SettingsViewModel.cs @@ -24,6 +24,9 @@ public partial class SettingsViewModel : ViewModelBase [ObservableProperty] private ObservableCollection _installDirectories = new(); + [ObservableProperty] + private int _maxInstallAttempts = 10; + // Media Settings [ObservableProperty] private string _mediaStoragePath = string.Empty; @@ -123,6 +126,8 @@ public partial class SettingsViewModel : ViewModelBase else InstallDirectories.Add(new InstallDirectoryItem(string.Empty)); + MaxInstallAttempts = settings.Games.MaxInstallAttempts; + // Media settings MediaStoragePath = settings.Media.StoragePath ?? string.Empty; @@ -170,6 +175,8 @@ public partial class SettingsViewModel : ViewModelBase .Select(d => d.Path) .ToArray(); + s.Games.MaxInstallAttempts = Math.Max(1, MaxInstallAttempts); + // Media settings s.Media.StoragePath = MediaStoragePath; diff --git a/LANCommander.Launcher/ViewModels/ShellViewModel.cs b/LANCommander.Launcher/ViewModels/ShellViewModel.cs index 2dabd029..93180eff 100644 --- a/LANCommander.Launcher/ViewModels/ShellViewModel.cs +++ b/LANCommander.Launcher/ViewModels/ShellViewModel.cs @@ -112,6 +112,9 @@ public partial class ShellViewModel : ViewModelBase public bool IsLibraryActive => !IsDepotActive; public bool CanGoOnline => IsOfflineMode && !IsCheckingConnection; + [ObservableProperty] + private bool _areUserLibrariesEnabled = true; + // Child view models public DepotViewModel DepotViewModel { get; private set; } = null!; public DepotBrowseViewModel DepotBrowseViewModel { get; private set; } = null!; @@ -216,13 +219,28 @@ public partial class ShellViewModel : ViewModelBase GameDetailViewModel.SearchRequested += OnSearchRequested; DownloadQueue.InstallCompleted += OnInstallCompleted; + DownloadQueue.ToolInstallCompleted += OnToolInstallCompleted; DownloadQueue.Initialize(); + if (!IsOfflineMode) + { + try + { + using var scope = _serviceProvider.CreateScope(); + var authenticationClient = scope.ServiceProvider.GetRequiredService(); + AreUserLibrariesEnabled = await authenticationClient.GetEnableUserLibrariesAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to fetch user library setting; defaulting to enabled"); + } + } + await ImportAndLoadAsync(); _ = Profile.LoadAsync(IsOfflineMode); - // Open to library if the user has games, otherwise show the depot - if (LibraryViewModel.Games.Count > 0) + // Open to library if user libraries are enabled and the user has games, otherwise show the depot + if (AreUserLibrariesEnabled && LibraryViewModel.Games.Count > 0) ShowLibrary(); else ShowDepot(); @@ -248,6 +266,11 @@ public partial class ShellViewModel : ViewModelBase IsLoading = false; } + // Fill the library instantly from the server (streaming media) so first-run and + // newly-added games appear before the slow import finishes caching them locally. + if (!IsOfflineMode) + _ = LibraryViewModel.LoadLibraryFromServerAsync(); + // Load depot data in the background so the UI is not blocked by network calls _ = LoadDepotInBackgroundAsync(); @@ -401,7 +424,8 @@ public partial class ShellViewModel : ViewModelBase [RelayCommand] private void GoOffline() { - if (IsOfflineMode) return; + if (IsOfflineMode) + return; IsOfflineMode = true; DepotViewModel.IsOfflineMode = true; @@ -416,8 +440,11 @@ public partial class ShellViewModel : ViewModelBase private async Task LogoutAsync() { using var scope = _serviceProvider.CreateScope(); + var authService = scope.ServiceProvider.GetRequiredService(); + await authService.Logout(); + LogoutRequested?.Invoke(this, EventArgs.Empty); } @@ -473,8 +500,10 @@ public partial class ShellViewModel : ViewModelBase } // Fallback: load from local database - var gameService = scope.ServiceProvider.GetRequiredService(); + var gameService = scope.ServiceProvider.GetRequiredService(); + var localGame = await gameService.GetAsync(gameId); + if (localGame != null) { var sdkGame = new SDK.Models.Game @@ -485,6 +514,7 @@ public partial class ShellViewModel : ViewModelBase Description = localGame.Description, ReleasedOn = localGame.ReleasedOn ?? DateTime.MinValue, }; + OnGameSelected(this, sdkGame); } } @@ -494,6 +524,41 @@ public partial class ShellViewModel : ViewModelBase } } + /// + /// Launches the game's primary action if it is installed. Used by the system tray + /// "recently played" shortcuts. Does nothing (beyond logging) if the game is not + /// installed or has no runnable action. + /// + public async Task RunGameByIdAsync(Guid gameId) + { + try + { + using var scope = _serviceProvider.CreateScope(); + var gameService = scope.ServiceProvider.GetRequiredService(); + + var localGame = await gameService.GetAsync(gameId); + + if (localGame is not { Installed: true }) + return; + + var gameClient = scope.ServiceProvider.GetRequiredService(); + var actions = await gameClient.GetActionsAsync(localGame.InstallDirectory, gameId); + var action = actions?.FirstOrDefault(a => a.IsPrimaryAction) ?? actions?.FirstOrDefault(); + + if (action == null) + { + _logger.LogWarning("No runnable action for game {GameId}", gameId); + return; + } + + await gameService.Run(localGame, action); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to run game {GameId}", gameId); + } + } + private void OnSearchRequested(object? sender, string term) { if (IsDepotActive) @@ -550,8 +615,10 @@ public partial class ShellViewModel : ViewModelBase private void NavigateToDepotBrowse(string? genre = null, string? tag = null, string? collection = null, string? search = null) { _lastDepotBrowseFilter = (genre, tag, collection, search); + DepotBrowseViewModel.Initialize(GamesListViewModel.GetAllGames(), genre, tag, collection, search); IsDepotActive = true; + _navigationService.NavigateTo(DepotBrowseViewModel); } @@ -560,6 +627,7 @@ public partial class ShellViewModel : ViewModelBase await LibraryViewModel.LoadGamesAsync(); await GamesListViewModel.LoadGamesAsync(); await DepotViewModel.LoadAsync(); + // Re-initialize the browse view with fresh data so "in library" badges update if (_navigationService.CurrentView == DepotBrowseViewModel) DepotBrowseViewModel.Initialize( @@ -593,11 +661,21 @@ public partial class ShellViewModel : ViewModelBase if (GameDetailViewModel.Id == gameId) await GameDetailViewModel.RefreshInstallStatusAsync(); } + + private async void OnToolInstallCompleted(object? sender, Guid gameId) + { + if (DepotGameDetailViewModel.Id == gameId) + await DepotGameDetailViewModel.RefreshInstallStatusAsync(); + + if (GameDetailViewModel.Id == gameId) + await GameDetailViewModel.RefreshInstallStatusAsync(); + } [RelayCommand] private void ShowSettings() { SettingsViewModel.Load(); + _navigationService.NavigateTo(SettingsViewModel); } diff --git a/LANCommander.Launcher/Views/AlertOverlay.axaml b/LANCommander.Launcher/Views/AlertOverlay.axaml index 326635fa..38c10cdf 100644 --- a/LANCommander.Launcher/Views/AlertOverlay.axaml +++ b/LANCommander.Launcher/Views/AlertOverlay.axaml @@ -37,7 +37,7 @@ - + + - + @@ -116,32 +117,7 @@ - - - - - - - - - - - - - - + diff --git a/LANCommander.Launcher/Views/Components/GameActionBarView.axaml.cs b/LANCommander.Launcher/Views/Components/GameActionBarView.axaml.cs index d6006c9b..7930170a 100644 --- a/LANCommander.Launcher/Views/Components/GameActionBarView.axaml.cs +++ b/LANCommander.Launcher/Views/Components/GameActionBarView.axaml.cs @@ -1,9 +1,6 @@ using System; -using System.Collections.Specialized; using Avalonia.Controls; -using Avalonia.Controls.Primitives; -using Avalonia.Input; -using Avalonia.Threading; +using LANCommander.Launcher.Controls; using LANCommander.Launcher.ViewModels.Components; namespace LANCommander.Launcher.Views.Components; @@ -11,7 +8,8 @@ namespace LANCommander.Launcher.Views.Components; public partial class GameActionBarView : UserControl { private GameActionBarViewModel? _vm; - private int _injectedItemCount; + private MenuFlyout? _installFlyout; + private MenuFlyout? _playFlyout; public GameActionBarView() { @@ -19,45 +17,34 @@ public partial class GameActionBarView : UserControl DataContextChanged += OnDataContextChanged; } - private MenuFlyout? PlayFlyout => PlaySplitButton.Flyout as MenuFlyout; - private void OnDataContextChanged(object? sender, EventArgs e) { - if (_vm != null) - _vm.SecondaryActions.CollectionChanged -= OnSecondaryActionsChanged; + // Tear down any flyouts built for the previous view model. + if (_installFlyout != null) + { + GameContextMenu.DetachBinder(_installFlyout); + InstallSplitButton.Flyout = null; + _installFlyout = null; + } + + if (_playFlyout != null) + { + GameContextMenu.DetachBinder(_playFlyout); + PlaySplitButton.Flyout = null; + _playFlyout = null; + } _vm = DataContext as GameActionBarViewModel; - if (_vm != null) - _vm.SecondaryActions.CollectionChanged += OnSecondaryActionsChanged; + if (_vm == null) + return; - RefreshSecondaryItems(); - } + // Both split buttons share the same consolidated, state-driven menu; only one is ever + // visible at a time, but each needs its own flyout instance. + _installFlyout = GameContextMenu.CreateFlyout(_vm); + _playFlyout = GameContextMenu.CreateFlyout(_vm); - private void OnSecondaryActionsChanged(object? sender, NotifyCollectionChangedEventArgs e) - => Dispatcher.UIThread.Post(RefreshSecondaryItems); - - private void RefreshSecondaryItems() - { - var flyout = PlayFlyout; - if (flyout is null || _vm is null) return; - - // Remove previously injected items (always at the front of the flyout) - for (var i = 0; i < _injectedItemCount; i++) - flyout.Items.RemoveAt(0); - _injectedItemCount = 0; - - if (_vm.SecondaryActions.Count == 0) return; - - var idx = 0; - foreach (var action in _vm.SecondaryActions) - { - flyout.Items.Insert(idx++, new MenuItem { Header = action.Name, Command = action.RunCommand }); - _injectedItemCount++; - } - - // Separator between secondary actions and static items - flyout.Items.Insert(idx, new Separator()); - _injectedItemCount++; + InstallSplitButton.Flyout = _installFlyout; + PlaySplitButton.Flyout = _playFlyout; } } diff --git a/LANCommander.Launcher/Views/Components/GenreCarouselButton.axaml b/LANCommander.Launcher/Views/Components/GenreCarouselButton.axaml index 38e131cf..35273283 100644 --- a/LANCommander.Launcher/Views/Components/GenreCarouselButton.axaml +++ b/LANCommander.Launcher/Views/Components/GenreCarouselButton.axaml @@ -1,16 +1,12 @@ - - - - - + + @@ -55,7 +55,7 @@ BorderThickness="0,0,1,0" Margin="0,48,0,0"> - @@ -177,6 +179,7 @@ diff --git a/LANCommander.Launcher/Views/LoginView.axaml b/LANCommander.Launcher/Views/LoginView.axaml index 9177ba39..96d20a72 100644 --- a/LANCommander.Launcher/Views/LoginView.axaml +++ b/LANCommander.Launcher/Views/LoginView.axaml @@ -70,7 +70,7 @@ - + - + - + - + + + + diff --git a/LANCommander.Launcher/Views/MainWindow.axaml.cs b/LANCommander.Launcher/Views/MainWindow.axaml.cs index d84a036e..1bb60767 100644 --- a/LANCommander.Launcher/Views/MainWindow.axaml.cs +++ b/LANCommander.Launcher/Views/MainWindow.axaml.cs @@ -1,3 +1,4 @@ +using System; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Interactivity; @@ -8,17 +9,34 @@ namespace LANCommander.Launcher.Views; public partial class MainWindow : Window { private WindowState _stateBeforeBigScreen = WindowState.Normal; + private bool _allowClose; + + /// Raised after the window hides to the tray (close intercepted). + public event EventHandler? HiddenToTray; public MainWindow() { InitializeComponent(); + Closing += (_, e) => + { + // Hide to the system tray instead of closing; the app keeps running. + if (_allowClose) + return; + + e.Cancel = true; + + Hide(); + + HiddenToTray?.Invoke(this, EventArgs.Empty); + }; + DataContextChanged += (_, _) => { if (DataContext is MainWindowViewModel vm) { vm.BigScreenModeChanged += OnBigScreenModeChanged; - vm.ExitLauncherRequested += (_, _) => Close(); + vm.ExitLauncherRequested += (_, _) => ExitApplication(); // Apply big screen mode if it was persisted or set via command line if (vm.IsBigScreenMode) @@ -37,16 +55,20 @@ public partial class MainWindow : Window WindowState = WindowState.FullScreen; } else - { WindowState = _stateBeforeBigScreen; - } } private void ResizeGrip_PointerPressed(object? sender, PointerPressedEventArgs e) { - if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return; - if (WindowState != WindowState.Normal) return; - if (sender is not Border { Name: var name }) return; + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) + return; + + if (WindowState != WindowState.Normal) + return; + + if (sender is not Border { Name: var name }) + return; + var edge = name switch { "ResizeNW" => WindowEdge.NorthWest, @@ -59,7 +81,9 @@ public partial class MainWindow : Window "ResizeSE" => WindowEdge.SouthEast, _ => (WindowEdge?)null }; - if (edge.HasValue) BeginResizeDrag(edge.Value, e); + + if (edge.HasValue) + BeginResizeDrag(edge.Value, e); } private void TitleBarDragRegion_PointerPressed(object? sender, PointerPressedEventArgs e) @@ -91,4 +115,29 @@ public partial class MainWindow : Window { Close(); } + + /// + /// Bring the window back to the foreground after it has been hidden to the + /// tray or minimized. Used by the tray and when a second launcher instance + /// asks the running one to surface. + /// + public void RestoreFromTray() + { + Show(); + + if (WindowState == WindowState.Minimized) + WindowState = WindowState.Normal; + + Activate(); + } + + /// + /// Fully exit the application, bypassing the close-to-tray behavior. + /// + public void ExitApplication() + { + _allowClose = true; + + Close(); + } } diff --git a/LANCommander.Launcher/Views/ServerSelectionView.axaml.cs b/LANCommander.Launcher/Views/ServerSelectionView.axaml.cs index 02d89ceb..5d34053d 100644 --- a/LANCommander.Launcher/Views/ServerSelectionView.axaml.cs +++ b/LANCommander.Launcher/Views/ServerSelectionView.axaml.cs @@ -1,23 +1,10 @@ -using System; using Avalonia.Controls; using Avalonia.Interactivity; -using Avalonia.Media.Imaging; -using Avalonia.Platform; namespace LANCommander.Launcher.Views; public partial class ServerSelectionView : UserControl { - private static readonly string[] Backgrounds = - { - "avares://LANCommander.Launcher/Assets/backgrounds/aoe2.jpg", - "avares://LANCommander.Launcher/Assets/backgrounds/ns2.jpg", - "avares://LANCommander.Launcher/Assets/backgrounds/css.jpg", - "avares://LANCommander.Launcher/Assets/backgrounds/bfme2.jpg", - "avares://LANCommander.Launcher/Assets/backgrounds/soldat2.jpg", - "avares://LANCommander.Launcher/Assets/backgrounds/ut2004.jpg", - }; - public ServerSelectionView() { InitializeComponent(); @@ -26,13 +13,8 @@ public partial class ServerSelectionView : UserControl private void OnLoaded(object? sender, RoutedEventArgs e) { - try - { - var uri = new Uri(Backgrounds[Random.Shared.Next(Backgrounds.Length)]); - BackgroundImage.Source = new Bitmap(AssetLoader.Open(uri)); - } - catch { /* silently ignore missing assets */ } - + ViewBackground.Apply(BackgroundImage); + ServerAddressTextBox.Focus(); } } diff --git a/LANCommander.Launcher/Views/SettingsView.axaml b/LANCommander.Launcher/Views/SettingsView.axaml index 9e9c9211..a0014de5 100644 --- a/LANCommander.Launcher/Views/SettingsView.axaml +++ b/LANCommander.Launcher/Views/SettingsView.axaml @@ -72,11 +72,22 @@ - + - - - - -