Compare commits

...
Sign in to create a new pull request.

152 commits

Author SHA1 Message Date
Pat Hartl
f08fd24a14 Release notes for 2.1.9
All checks were successful
Publish Documentation Updates / dispatch (push) Successful in 9s
2026-07-27 20:09:00 -05:00
Pat Hartl
da94ff8438 Fix detection of primary display's resolution on some Linux multi-display configurations
Some checks failed
LANCommander Release / prep (push) Failing after 49s
LANCommander Release / build_server_linux_arm64 (push) Has been skipped
LANCommander Release / build_server_linux_x64 (push) Has been skipped
LANCommander Release / build_server_osx_arm64 (push) Has been skipped
LANCommander Release / build_server_osx_x64 (push) Has been skipped
LANCommander Release / build_server_win_arm64 (push) Has been skipped
LANCommander Release / build_server_win_x64 (push) Has been skipped
LANCommander Release / build_launcher_linux_arm64 (push) Has been skipped
LANCommander Release / build_launcher_linux_x64 (push) Has been skipped
LANCommander Release / build_launcher_osx_arm64 (push) Has been skipped
LANCommander Release / build_launcher_osx_x64 (push) Has been skipped
LANCommander Release / build_launcher_win_arm64 (push) Has been skipped
LANCommander Release / build_launcher_win_x64 (push) Has been skipped
LANCommander Release / build_packager (push) Has been skipped
LANCommander Release / build_release (push) Has been skipped
2026-07-27 00:26:12 -05:00
Pat Hartl
41bcfa908d Unify path resolution server-wide 2026-07-24 18:00:32 -05:00
Pat Hartl
2ca1db535c Fix app path resolution for server, add better tests 2026-07-24 17:35:58 -05:00
Pat Hartl
3eae04abdd Better handle bypass execution policy for scripts 2026-07-24 00:57:29 -05:00
Pat Hartl
2b10b55b38 Create abstraction around elevated process launching for better testing 2026-07-23 21:31:47 -05:00
Pat Hartl
eb5abc2c01 Expose internal bits of SDK to test project 2026-07-21 18:14:22 -05:00
Pat Hartl
9e7dc978df Increase performance of depot queries 2026-07-21 18:01:44 -05:00
Pat Hartl
4acf5936c5 Add shorter timeout for server notifications 2026-07-21 18:01:34 -05:00
Pat Hartl
a5b36e1e66 Fix SDK tests 2026-07-21 17:45:34 -05:00
Pat Hartl
e23a5c3eca Add tests for installing to an existing game install 2026-07-21 17:45:17 -05:00
Pat Hartl
a5ab9aba58 Update SharpCompress 2026-07-21 17:22:06 -05:00
Pat Hartl
28d640c888 Fix installation of wine32 and winetricks 2026-07-17 21:30:05 -05:00
Pat Hartl
d37bc13255 v2.1.8 release notes
Some checks failed
LANCommander Release / prep (push) Failing after 17s
LANCommander Release / build_launcher_linux_x64 (push) Has been skipped
LANCommander Release / build_launcher_osx_arm64 (push) Has been skipped
LANCommander Release / build_launcher_osx_x64 (push) Has been skipped
LANCommander Release / build_launcher_win_arm64 (push) Has been skipped
LANCommander Release / build_launcher_win_x64 (push) Has been skipped
LANCommander Release / build_packager (push) Has been skipped
LANCommander Release / build_release (push) Has been skipped
LANCommander Release / build_server_linux_arm64 (push) Has been skipped
LANCommander Release / build_server_linux_x64 (push) Has been skipped
LANCommander Release / build_server_osx_arm64 (push) Has been skipped
LANCommander Release / build_server_osx_x64 (push) Has been skipped
LANCommander Release / build_server_win_arm64 (push) Has been skipped
LANCommander Release / build_server_win_x64 (push) Has been skipped
LANCommander Release / build_launcher_linux_arm64 (push) Has been skipped
2026-07-08 18:59:36 -05:00
Pat Hartl
f31925e399 Add runtime platform flag to actions, scripts, and save paths 2026-07-08 02:15:54 -05:00
Pat Hartl
36bc769211 Move offline mode button to the titlebar 2026-07-07 01:53:38 -05:00
Pat Hartl
ec8af3f268 Log any version mismatch 2026-07-07 01:53:22 -05:00
Pat Hartl
f538ae4c81 Handle version mismatching 2026-07-07 01:50:25 -05:00
Pat Hartl
6c6a0245c6 Load images from local when in offline mode 2026-07-07 01:49:46 -05:00
Pat Hartl
04dff90a63 Convert description editor to markdown editor 2026-07-06 04:24:42 -05:00
Pat Hartl
331ebf6155 Improvements to Page functionality
Fixes various issues with page functionality, adds better menus for adding/deleting pages, changes slugs to PascalCase.
2026-07-06 02:11:36 -05:00
Pat Hartl
833535c1d9 Persist datatable sort 2026-07-05 16:25:48 -05:00
Pat Hartl
09a78d9da1 Fix enumeration of script snippets 2026-07-05 16:24:58 -05:00
Pat Hartl
99569f65c0 Fix PowerShell module loading in launcher
When running Windows, set execution policy to bypass. Load modules and trap exceptions that might be thrown. This outputs a warning message per PS module instead of full script execution failing.
2026-07-05 14:43:02 -05:00
Pat Hartl
08f5b5f76d Release notes for v2.1.7
Some checks failed
LANCommander Release / prep (push) Failing after 17s
LANCommander Release / build_launcher_linux_x64 (push) Has been skipped
LANCommander Release / build_launcher_osx_arm64 (push) Has been skipped
LANCommander Release / build_launcher_osx_x64 (push) Has been skipped
LANCommander Release / build_launcher_win_arm64 (push) Has been skipped
LANCommander Release / build_launcher_win_x64 (push) Has been skipped
LANCommander Release / build_packager (push) Has been skipped
LANCommander Release / build_release (push) Has been skipped
LANCommander Release / build_server_linux_arm64 (push) Has been skipped
LANCommander Release / build_server_linux_x64 (push) Has been skipped
LANCommander Release / build_server_osx_arm64 (push) Has been skipped
LANCommander Release / build_server_osx_x64 (push) Has been skipped
LANCommander Release / build_server_win_arm64 (push) Has been skipped
LANCommander Release / build_server_win_x64 (push) Has been skipped
LANCommander Release / build_launcher_linux_arm64 (push) Has been skipped
2026-07-05 02:03:11 -05:00
Pat Hartl
16495770ba Load target view's data before transitioning 2026-07-05 01:29:56 -05:00
Pat Hartl
545b5b1f43 Default to klling process if there's no spawned window 2026-07-04 15:07:13 -05:00
Pat Hartl
c292979010 Fix persisted data table page sizes 2026-07-04 14:51:11 -05:00
Pat Hartl
6e954ff5b8 Log exceptions from save upload failures 2026-07-04 14:27:00 -05:00
Pat Hartl
6bcbc0e9e5 Ensure game is updated when save path is added/updated/deleted 2026-07-04 14:26:25 -05:00
Pat Hartl
38db011de8 Show library before it's fully imported
Loads the user's library from the /Library/Games endpoint and displays it immediately. This route returns a full data set for the user's games. Images are rendered directly from the server. The import still runs in the background, preserving any offline functionality.
2026-07-04 03:33:13 -05:00
Pat Hartl
c26d9cfabb Add endpoint to list full game info for user library 2026-07-04 02:35:09 -05:00
Pat Hartl
b034736822 Avoid downloading depot images to disk, improve UI jank
Fixes #427
2026-07-04 00:14:33 -05:00
Pat Hartl
862f266275 Reorder components of game installations
Game installs now install items in the following order:
1. Base Game
2. Addons
3. Tools
4. Redistributables

Ref #414
2026-07-02 20:42:21 -05:00
Pat Hartl
e287d3ce8a Refresh game action bar after tool install
Ref #414
2026-07-02 20:34:22 -05:00
Pat Hartl
8d96acc98d Ensure tools are uninstalled before game is uninstalled
Ref #414
2026-07-02 20:21:08 -05:00
Pat Hartl
c07161a1a3 Ensure game cache is expired 2026-07-02 20:01:04 -05:00
Pat Hartl
6ccf53de21 Fix importing legacy LCX files with no save paths defined
Fixes #426
2026-07-02 18:14:46 -05:00
Pat Hartl
ee5c9017d5 Get list of installed addons for modify menu 2026-07-01 20:41:08 -05:00
Pat Hartl
04a715e9cb Fix install of games/addons that have no dependent games 2026-07-01 19:37:04 -05:00
Pat Hartl
7595f4d71c Fix inheritance
Some checks failed
LANCommander Release / prep (push) Failing after 14s
LANCommander Release / build_server_linux_x64 (push) Has been skipped
LANCommander Release / build_server_osx_arm64 (push) Has been skipped
LANCommander Release / build_server_osx_x64 (push) Has been skipped
LANCommander Release / build_server_win_arm64 (push) Has been skipped
LANCommander Release / build_server_win_x64 (push) Has been skipped
LANCommander Release / build_launcher_linux_arm64 (push) Has been skipped
LANCommander Release / build_launcher_linux_x64 (push) Has been skipped
LANCommander Release / build_launcher_osx_arm64 (push) Has been skipped
LANCommander Release / build_launcher_osx_x64 (push) Has been skipped
LANCommander Release / build_launcher_win_arm64 (push) Has been skipped
LANCommander Release / build_launcher_win_x64 (push) Has been skipped
LANCommander Release / build_packager (push) Has been skipped
LANCommander Release / build_release (push) Has been skipped
LANCommander Release / build_server_linux_arm64 (push) Has been skipped
2026-06-28 23:22:57 -05:00
Pat Hartl
5c1e011f0a Add release notes for v2.1.6 2026-06-28 23:17:08 -05:00
Pat Hartl
f87756243e Add support for defining PowerShell modules
Adds a new section "Scripting" in the server UI where PowerShell modules can be defined. These can be used for defining a library of functions that can be used in any script. These modules are automatically synced to the launcher and imported upon script execution.
2026-06-28 23:02:35 -05:00
Pat Hartl
5a80d1e605 Refactor connection string input into full database connection component
Fixes #256
2026-06-28 18:23:58 -05:00
Pat Hartl
69f8447cae Fix tests, add tests for user limits 2026-06-28 14:09:34 -05:00
Pat Hartl
fa1559c367 Add limit options for saves, user storage, and download speed
Implements #85, #84, #100
2026-06-28 14:09:02 -05:00
Pat Hartl
8fdb6f74cb Fix display of UTC times 2026-06-28 11:47:57 -05:00
Pat Hartl
68dece3f1b Preserve existing values in metadata lookup
Fixes #420
2026-06-27 23:19:50 -05:00
Pat Hartl
2f0e0daa3c Add "Select All" and "Load More" buttons to media grabber
Fixes #417
2026-06-27 23:13:11 -05:00
Pat Hartl
a6bf885340 Add dialog for creating users as admin
Fixes #419
2026-06-27 19:06:21 -05:00
Pat Hartl
171e7dcaf0 Invalidate profile cache when logging out
Fixes #422
2026-06-27 18:44:24 -05:00
Pat Hartl
e485ad1865 Fix reconciliation of library games based on user libraries enabled/disabled
Ref #423
2026-06-27 18:38:42 -05:00
Pat Hartl
4d32f958cc Additional gating of UI for user libraries
Ref #423
2026-06-27 18:18:55 -05:00
Pat Hartl
2910bc9476 If user libraries are disabled, hide Library from web UI
Ref #423
2026-06-27 18:14:22 -05:00
Pat Hartl
ecdfc6b4e4 Hide library/depot switcher if user libraries are disabled 2026-06-27 18:12:38 -05:00
Pat Hartl
8760b2d961 Default action ServerHost value to LANCommander server address 2026-06-27 17:59:17 -05:00
Pat Hartl
f53d5bfc4c Add "Always Install" option to tools
Ref #414
2026-06-27 12:11:17 -05:00
Pat Hartl
b21efc63d0 Track tools per game
Ref #414
2026-06-27 11:51:55 -05:00
Pat Hartl
fb0df46572 Uninstall tools when uninstalling game
Ref #414
2026-06-27 11:33:39 -05:00
Pat Hartl
f412f308a2 Fix creation of entities with many-to-many relationships
Ref #414
2026-06-27 11:28:39 -05:00
Pat Hartl
a778a56b18 2.1.5 release notes 2026-06-25 22:43:11 -05:00
Pat Hartl
d3a363bae5 Hide tools that have no archive from install overlay
Some checks failed
LANCommander Release / prep (push) Failing after 11s
LANCommander Release / build_server_win_x64 (push) Has been skipped
LANCommander Release / build_launcher_linux_arm64 (push) Has been skipped
LANCommander Release / build_launcher_linux_x64 (push) Has been skipped
LANCommander Release / build_launcher_osx_arm64 (push) Has been skipped
LANCommander Release / build_launcher_osx_x64 (push) Has been skipped
LANCommander Release / build_launcher_win_arm64 (push) Has been skipped
LANCommander Release / build_launcher_win_x64 (push) Has been skipped
LANCommander Release / build_packager (push) Has been skipped
LANCommander Release / build_release (push) Has been skipped
LANCommander Release / build_server_linux_arm64 (push) Has been skipped
LANCommander Release / build_server_linux_x64 (push) Has been skipped
LANCommander Release / build_server_osx_arm64 (push) Has been skipped
LANCommander Release / build_server_osx_x64 (push) Has been skipped
LANCommander Release / build_server_win_arm64 (push) Has been skipped
2026-06-25 22:26:59 -05:00
Pat Hartl
656fbfbcbb Fix game tools API request response 2026-06-25 22:04:57 -05:00
Pat Hartl
956c2c3b8a Fix exception thrown when uninstalling game 2026-06-25 22:04:37 -05:00
Pat Hartl
bd81e97037 Fix clickability of game in compact library list 2026-06-25 21:41:05 -05:00
Pat Hartl
84c0c5eff6 Fix updating of roles 2026-06-25 19:55:21 -05:00
Pat Hartl
0973c00f1e Fix removing games from library, consolidate flyout menu 2026-06-25 19:46:30 -05:00
Pat Hartl
33e6d0f4a3 Add missing endpoint for getting tools per game
Ref #414
2026-06-25 02:05:15 -05:00
Pat Hartl
a9f74ef78f Expose "Exit" button under profile dropdown
Fixes #416
2026-06-25 01:55:26 -05:00
Pat Hartl
2a64a621fc Tests for first time setup creation of administrator account 2026-06-25 01:51:42 -05:00
Pat Hartl
97dbcdc73a Ensure normalized username and email are populated
Fixes #415
2026-06-25 01:23:59 -05:00
Pat Hartl
e64439df66
Merge pull request #396 from aaronpowell/spike/ui-automated-tests
Spike: Playwright UI automated tests for server application
2026-06-25 00:20:31 -05:00
Pat Hartl
12d63c02a2 Stabilize Playwright smoke layer for CI
The remaining Playwright failures were all in interactions that depend on
the Blazor Server circuit being responsive in CI:

- Settings page access now navigates to /Settings/General directly instead
  of expanding the flaky nested Settings SubMenu flyout.
- Drop SettingsMenu_ShowsAllExpectedSubItems; the submenu-expansion is the
  unreliable interaction the bUnit migration exists to replace, and the
  Settings pages are now covered by bUnit component tests.
- Remove the GameImport UI tests (and unused GamesPage/OpenRCT2.lcx). The
  ChunkUploader modal never renders under the in-process test host, so they
  failed in every CI run; the Games list render is already covered by
  AdminNavigationTests.GamesPage_ShowsEmptyTable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 23:14:42 -05:00
Pat Hartl
004a254c37 Migrate flaky Playwright admin UI tests to bUnit
Replace the SignalR-circuit-race-prone Playwright component tests for
GameEdit, Settings, metadata, profile, roles and users with in-process
bUnit component tests. Keep a thin Playwright smoke layer for the true
E2E paths (login, admin navigation/routing, first-time setup, game
import) that bUnit cannot cover.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 20:45:21 -05:00
Pat Hartl
af63a20bd2 Add GameDetailView visual baselines from Linux CI render
Now that both GameDetailView layout tests render (INavigationService +
MultiplyConverter fixes), capture their deterministic CI screenshots as
baselines. Completes the launcher visual baseline regeneration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 18:48:19 -05:00
Pat Hartl
e76440c53e Fix GameDetailView visual tests failing to render
GameDetailViewModel resolves INavigationService from DI, and
GameDetailView.axaml references the MultiplyConverter declared in the
real App.axaml. Neither was present in the test harness, so both
GameDetailView layout tests threw before producing a screenshot.
Register the real NavigationService and mirror the MultiplyConverter
into TestApp so the views render. Baselines still need a fresh CI
capture since these tests never previously rendered.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 18:35:51 -05:00
Pat Hartl
99131c4b86 Regenerate launcher visual baselines from Linux CI render
Replaces 9 stale/corrupt baselines with fresh deterministic captures
from the CI Linux renderer. The three background views now render with
the random background disabled (ViewBackground seam); the remaining six
update corrupt mid-transition captures (e.g. overlapping Run/Stop text)
and cross-platform font differences.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 18:16:31 -05:00
Pat Hartl
860db8ff8f Make launcher view backgrounds deterministic in visual tests
LoginView, SplashView and ServerSelectionView each picked a random
full-screen background on load, so the visual-regression baselines could
never match — every run compared against a different photo and reported a
spurious 82-94% regression. Centralize the selection in ViewBackground and
let tests disable it, so captured screenshots and baselines are stable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 02:03:09 -05:00
Pat Hartl
6a2b5177b0 Use SQLite instead of EF InMemory for UI tests
The DataTable component issues relational queries (AsSplitQuery, Include,
and a translated punctuation-stripping search expression) that the EF
InMemory provider cannot translate. On CI this surfaced as a native stack
overflow in CountAsync that crashed the in-process server and cascaded
into widespread timeouts. A file-based SQLite database supports these
queries and concurrent connections, stabilizing the test run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 00:39:44 -05:00
Pat Hartl
97c899f6f2 Generate PowerShell completions in ui_tests CI job
The Monaco editor's PowerShellCompletions.g.ts is gitignored and required
by the frontend webpack build. The in-build MSBuild target uses Windows
path separators that don't resolve on the Linux runner, so the ui_tests
job must generate the file explicitly first — mirroring the server build
jobs which already do this.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 22:26:42 -05:00
Pat Hartl
f2a864b540 Update UI tests for post-merge changes from main
- Target net10.0 to match the now-net10 server project (and fix the
  Playwright browser-install path in the PR workflow accordingly).
- Dismiss the persistent "Import Ready" notification (added with main's
  background UploadTracker) before clicking Import; its Duration=0 toast
  overlays and intercepts the modal's Import button.
- Update General settings assertions: "Database Provider" moved to the new
  dedicated /Settings/Database page, so assert on "Use SSL" instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 22:11:05 -05:00
Pat Hartl
20a2e0a1af Merge remote-tracking branch 'origin/main' into pr-396
# Conflicts:
#	.github/workflows/LANCommander.PR.yml
#	LANCommander.slnx
2026-06-23 21:13:19 -05:00
Pat Hartl
65c00fe9e1 Fix game import test: set file on InputFile directly
Replace the FileChooser approach with SetInputFilesAsync targeting the
ChunkUploader's hidden InputFile (matched by id prefix), which correctly
drives Blazor's OnChange and enables the Upload button. Also wait on the
actual Upload button's enabled state instead of the first primary button
(which matched the always-enabled "Browse" button and passed spuriously).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 21:03:16 -05:00
Pat Hartl
9b5195f1d6 Use web-first Playwright assertions in UI tests
Replace non-waiting Assert.True(await locator.IsVisibleAsync()) checks with
auto-retrying Assertions.Expect(locator).ToBeVisibleAsync()/ToBeHiddenAsync()
to remove flakiness against Blazor's async rendering. Table-row/field page
objects (Metadata/Roles/Users/Profile) now expose ILocator helpers instead of
Task<bool>, so deletion checks wait for the element to disappear.

Also: rename ScreenshotHelper.CaptureIfFailedAsync to CaptureAsync (xUnit v2
cannot expose the test outcome to DisposeAsync, so it always captures the final
page state), drop the unused TestConstants.ServerPort/BaseUrl now that ports
bind dynamically, and fix IClassFixture doc comments to ICollectionFixture.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-23 17:51:34 -05:00
Pat Hartl
46801dd468 Hide auth fields / buttons based on server auth settings
Some checks failed
LANCommander Release / prep (push) Failing after 12s
LANCommander Release / build_launcher_linux_x64 (push) Has been skipped
LANCommander Release / build_launcher_osx_arm64 (push) Has been skipped
LANCommander Release / build_launcher_osx_x64 (push) Has been skipped
LANCommander Release / build_launcher_win_arm64 (push) Has been skipped
LANCommander Release / build_launcher_win_x64 (push) Has been skipped
LANCommander Release / build_packager (push) Has been skipped
LANCommander Release / build_release (push) Has been skipped
LANCommander Release / build_server_linux_arm64 (push) Has been skipped
LANCommander Release / build_server_linux_x64 (push) Has been skipped
LANCommander Release / build_server_osx_arm64 (push) Has been skipped
LANCommander Release / build_server_osx_x64 (push) Has been skipped
LANCommander Release / build_server_win_arm64 (push) Has been skipped
LANCommander Release / build_server_win_x64 (push) Has been skipped
LANCommander Release / build_launcher_linux_arm64 (push) Has been skipped
2026-06-22 21:46:49 -05:00
Pat Hartl
b6e674e2eb Fix saving of authentication settings 2026-06-22 21:08:59 -05:00
Pat Hartl
42ca077416 Add auto redirect to provider to launcher 2026-06-22 21:08:48 -05:00
Pat Hartl
f5c5bbee70 v2.1.4 release notes 2026-06-22 19:45:57 -05:00
Pat Hartl
24eaa79885 Add tool to repack non-streamable ZIP archives
Adds a tool that checks archives to see if they use data descriptors instead of local entry headers for tracking entry size. It will also repack these archives if required.
2026-06-22 19:18:11 -05:00
Pat Hartl
062e59268b Archive documentation
Describes how archives should be created and common pitfalls that archiving tools can make.
2026-06-22 18:07:37 -05:00
Pat Hartl
a78978b10c Fix mapping for save endpoints 2026-06-22 17:09:50 -05:00
Pat Hartl
516465ad1a Add WebP support for media 2026-06-22 17:05:47 -05:00
Pat Hartl
9bc44500fc Update "Last Played" text every minute, localize 2026-06-21 23:33:52 -05:00
Pat Hartl
2e8e4ae3ae Allow selected local files for archive upload to be moved instead of copied
Fixes #413
2026-06-21 23:00:56 -05:00
Pat Hartl
6b2610a200 Rework Tool installation / action resolution
Amends the tool installation to use game install directory and correctly populate actions. Tools previously used an undocumented/unpopulated/inaccessible global install directory. This has been changed to use the game's install directory. Uninstall scripts are also now executed for tools when the game is uninstalled.

Fixes #414
2026-06-21 21:16:45 -05:00
Pat Hartl
23a0a14e57 Auth provider documentation 2026-06-21 02:04:53 -05:00
Pat Hartl
f1fa66b632 Improve advanced auth provider editing, add role claim mapping
- Added auto discovery of scopes and claim mappings to auth provider editor
- Added the ability to map claims to roles
- Auto-provision users logged in over external auth

Ref #411
2026-06-21 02:03:51 -05:00
Pat Hartl
0f03461f53 Add setting to auto redirect to external provider
When enabled, AutoRedirectToProvider will switch authentication challenges to redirect to the external auth provider instead of displaying the password login form. If more than one external provider is configured, a minimal external auth provider login page will be shown.

Ref #410
2026-06-21 01:59:15 -05:00
Pat Hartl
ca7601e218 Mutate user from UserManager queries
Fixes #412
2026-06-20 23:23:38 -05:00
Pat Hartl
000b12ca1c Fix error in launcher when tool actions are not defined 2026-06-20 04:26:04 -05:00
Pat Hartl
b27b1f0ad4 Only display notification when entire install chain is complete 2026-06-20 04:25:01 -05:00
Pat Hartl
54fd3ac0b7 Adjust styling for buttons, dropdowns 2026-06-20 04:24:20 -05:00
Pat Hartl
6e7bcccf45 Add more logging to archive extraction, allow customization of retry attempts on install 2026-06-20 03:33:29 -05:00
Pat Hartl
04a710e101 Add message indicating server needs to be restarted after applying changes to authentication providers 2026-06-19 22:41:37 -05:00
Pat Hartl
72bac8240a Run game started/game stopped server scripts as fire and forget 2026-06-19 22:41:15 -05:00
Pat Hartl
fa63e8e95a Add uninstall script to Redistributables 2026-06-19 20:37:13 -05:00
Pat Hartl
a8709cd05b Fix server game actions loading from server 2026-06-19 20:17:47 -05:00
Pat Hartl
0ed8a80fbb Only allow one instance of the launcher to run at a time
Restores launcher from the system tray on subsequent runs
2026-06-19 19:37:31 -05:00
Pat Hartl
fe8ef32058 Update SharpCompress to 0.49.1 2026-06-19 19:29:47 -05:00
Pat Hartl
5e59e1ca17 Filter non-standalone addons from library compact list 2026-06-18 01:24:15 -05:00
Pat Hartl
1cb30dda24 Fix icons, bundle appimage in normal workflows 2026-06-17 09:36:23 -05:00
Pat Hartl
d676e2e25c Don't swallow errors for HQ authentication 2026-06-17 02:49:16 -05:00
Pat Hartl
fa81d4da6e Generate .app for macOS 2026-06-17 02:45:29 -05:00
Pat Hartl
180fabac97 Generate Linux appimage 2026-06-17 02:45:20 -05:00
Pat Hartl
e231828dd8 Fix list of play sessions under game
Some checks failed
LANCommander Release / prep (push) Failing after 17s
LANCommander Release / build_server_linux_arm64 (push) Has been skipped
LANCommander Release / build_server_linux_x64 (push) Has been skipped
LANCommander Release / build_server_osx_arm64 (push) Has been skipped
LANCommander Release / build_server_osx_x64 (push) Has been skipped
LANCommander Release / build_server_win_arm64 (push) Has been skipped
LANCommander Release / build_server_win_x64 (push) Has been skipped
LANCommander Release / build_launcher_linux_arm64 (push) Has been skipped
LANCommander Release / build_launcher_linux_x64 (push) Has been skipped
LANCommander Release / build_launcher_osx_arm64 (push) Has been skipped
LANCommander Release / build_launcher_osx_x64 (push) Has been skipped
LANCommander Release / build_launcher_win_arm64 (push) Has been skipped
LANCommander Release / build_launcher_win_x64 (push) Has been skipped
LANCommander Release / build_packager (push) Has been skipped
LANCommander Release / build_release (push) Has been skipped
2026-06-16 01:07:22 -05:00
Pat Hartl
6b4ba041e9 v2.1.3 release notes 2026-06-16 01:07:08 -05:00
Pat Hartl
f17932da5c Include game and user, protect against null game 2026-06-15 23:19:05 -05:00
Pat Hartl
c1aca1572e Add server-side tool to optimize images 2026-06-15 21:45:14 -05:00
Pat Hartl
1ec905420f Add tool to delete long play sessions 2026-06-15 21:04:02 -05:00
Pat Hartl
f17afdd54b Populate working directory from PCGamingWiki save paths 2026-06-15 20:52:54 -05:00
Pat Hartl
68edf9b9f4 Download self-container build of yt-dlp 2026-06-15 20:44:38 -05:00
Pat Hartl
5a29943d44 Fix updating for relationships with stale/untracked entities 2026-06-15 20:25:09 -05:00
Pat Hartl
86e1d6075a Adjust styling in game description 2026-06-15 20:19:03 -05:00
Pat Hartl
99cf4cdc51 Launcher game details performance optimizations
- Changed scroll transition on video/screenshot carousel to use RenderTransform instead of margin to cut down on layout passes for each frame. This should result in a significant drop in CPU usage when scrolling.
- Pause playback of videos when scrolled out of view or window is no longer active.
- Removed additional InlineVideoPlayer controls from being created for each item in the media carousel, and instead only created for videos. This should result in a significant drop in RAM allocation when scrolling.
- Rework caching mechanism for covers to have an upper limit of allocated RAM.
- Only decode screenshots / covers at Hi DPI size in which they are displayed at. This should ensure crisp display while only allocating the amount of RAM needed for display.
2026-06-15 19:37:46 -05:00
Pat Hartl
1ceb569a20 Fix extra gap on compact list scrollbar 2026-06-14 23:50:31 -05:00
Pat Hartl
814d0b3395 Add "Allow Registration" setting to server
Fixes #408
2026-06-14 21:46:29 -05:00
Pat Hartl
9d98e8059a Close to system tray 2026-06-14 21:24:07 -05:00
Pat Hartl
07255ab552 Update Notify.NET, fix progress in taskbar 2026-06-14 19:40:47 -05:00
Pat Hartl
d9501aea4e Localize remaining time and install progress status 2026-06-14 18:20:13 -05:00
Pat Hartl
f7a036453f Remove dead code, fix loading of videos/screenshots blocking UI 2026-06-14 01:58:52 -05:00
Pat Hartl
8cac65232a Fix missing "Starting" text in play button when game is starting 2026-06-14 01:01:04 -05:00
Pat Hartl
ecaecd21bb Send keepalive while game is running
Sends a keepalive/heartbeat signal across RPC while a game is running. Server-side this enables the ability to manage play session state with realtime information instead of hoping that the client will stop the play session. Keepalives are tracked in server cache allowing for future potential horizontally-scaled server instances.
2026-06-14 00:53:45 -05:00
Pat Hartl
ceb0eeb36b Move server lifecycle management to ServerManager. Add autostop delay to servers. 2026-06-14 00:53:38 -05:00
Pat Hartl
c4fa0c7335 Handle Process killing via extension method w/ libc import on Linux 2026-06-14 00:53:31 -05:00
Pat Hartl
268d66eed5
Merge pull request #407 from LANCommander/refactor/database-service-scope
Refactor/database service scope
2026-06-13 01:46:29 -05:00
Aaron Powell
77b0f04e4d Fix file upload: use FileChooser API for Blazor InputFile compatibility
Blazor Server's InputFile component doesn't reliably process synthetic
FileList objects from Playwright's SetInputFilesAsync on headless Linux.
Switch to clicking the upload label to trigger the native file dialog,
then intercept it with Playwright's FileChooser API. This triggers a
real browser file selection that Blazor always recognizes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 12:56:49 +11:00
Aaron Powell
0c3ed7cb77 Capture screenshots on test completion for CI diagnostics
- Add ScreenshotHelper that captures full-page screenshots for every test
- Screenshots are named with the full test display name for easy correlation
- Save to SCREENSHOT_DIR env var (CI) or bin/Screenshots/ (local)
- Add ITestOutputHelper to all 10 test classes for test name extraction
- Screenshots are included in the existing ui-test-results artifact upload

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 12:33:49 +11:00
Aaron Powell
952af65b2c Fix CI: decouple GameEditTests from import, add test-reporter
- GameEditTests now use a game seeded via the service layer in
  ConfiguredServerFixture instead of importing via the UI.
  This eliminates the Blazor InputFile compatibility issue on Linux CI.
- GamesPage.ImportGameAsync uses SetInputFilesAsync with retry and
  manual change event dispatch for Blazor InputFile compatibility.
- Add dorny/test-reporter to display test results in PR summary.
- Add checks/pull-requests write permissions for test-reporter.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 12:07:52 +11:00
Aaron Powell
951c1a08a0 Fix game import file upload for headless Linux CI
Use Playwright's FileChooser API instead of SetInputFilesAsync on the
hidden file input. AntDesign's Upload component JS interop doesn't
recognize files set directly on the hidden input in headless Linux,
leaving the Upload button disabled. The FileChooser approach intercepts
the native file dialog, which AntDesign processes normally.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 10:05:28 +11:00
Aaron Powell
f3ea535e0d Fix CI: add Node.js setup for UI project webpack build
The LANCommander.UI project runs 'npm run package' during build,
which requires Node.js and npm dependencies to be installed first.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 09:39:01 +11:00
Aaron Powell
373ad2464f Add UI tests job to PR workflow
Runs Playwright UI tests on ubuntu-latest with Chromium.
Only builds and tests the LANCommander.Server.UI.Tests project.
Uploads test results as artifacts for inspection on failure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 09:30:34 +11:00
Aaron Powell
c856a51504 Fix stack overflow on test disposal with FreshServerFixture
- Refactor FirstTimeSetupTests to use FreshServerFixture via ICollectionFixture
  instead of creating its own UITestApplicationFactory per test
- Skip _realHost.Dispose() in UITestApplicationFactory since the DI container's
  deep dependency chain causes an uncatchable native StackOverflowException
- All 62 tests pass with exit code 0 (61 pass, 1 skip)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 08:41:11 +11:00
Aaron Powell
0bac5e7b8b Move test .lcx file into repo under TestData
Copy OpenRCT2.lcx into TestData/ and reference via AppContext.BaseDirectory
so tests don't depend on a user-specific Downloads path.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 08:25:45 +11:00
Aaron Powell
39ebe66c73 Add game editing UI tests with page object
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 08:25:44 +11:00
Aaron Powell
fd0c15bf8a Fix full test suite: use ICollectionFixture and remove Hangfire
- Switch all test classes from IClassFixture<ConfiguredServerFixture> to
  [Collection("Server")] with ICollectionFixture so a single server instance
  is shared across all test classes (avoids static DatabaseContext.Provider conflicts)
- Put FirstTimeSetupTests in its own collection since it needs an unconfigured server
- Remove Hangfire hosted services in UITestApplicationFactory to prevent
  stack overflow during process shutdown (deep DI disposal chain)
- Add graceful shutdown handling with timeout in DisposeAsync
- All 54 tests pass, 1 skipped, clean exit code 0

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 08:25:44 +11:00
Aaron Powell
3e6c59e384 Add game import UI tests with page object and storage location seeding
- Create GamesPage page object with NavigateAsync, GetGameCountAsync,
  IsGameVisibleAsync, ImportGameAsync, and OpenGameEditAsync methods
- Create GameImportTests with 5 tests covering empty state, import
  button visibility, LCX file import, imported game visibility, and
  game edit navigation
- Seed default storage locations (Archive, Save, Media) in
  ConfiguredServerFixture to support import dialog initialization
- Handle hidden file input upload via Playwright SetInputFilesAsync
- Scope modal interactions to .ant-modal-wrap to avoid button
  ambiguity with the page toolbar

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 08:25:44 +11:00
Aaron Powell
3cc9e47461 Add user management UI tests with page object
- Create UsersPage page object with navigate, search, user visibility,
  roles check, and delete methods
- Add UserManagementTests with 5 tests: admin user visibility, search,
  roles display, new user registration, and user deletion
- Fix GamesPage.cs syntax error (unchecked is a C# keyword)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 08:25:44 +11:00
Aaron Powell
d9d6e7a3c9 Add Settings page UI tests
Create SettingsPage page object with navigation helpers for all settings
sub-pages and SettingsTests with 9 tests covering General form elements,
Users list, Roles list, Authentication, Archives, Media, Beacon, Updates,
and Appearance settings pages.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 08:25:44 +11:00
Aaron Powell
7a90eb1d48 Add metadata CRUD UI tests for Tags, Genres, and Platforms
Create a reusable MetadataPage page object that encapsulates navigation,
add/edit/delete operations, and item visibility checks for any metadata
type. Add MetadataTests with 8 tests covering:
- Tags: empty state, add, edit, delete
- Genres: add, delete
- Platforms: add, delete

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 08:25:44 +11:00
Aaron Powell
4f528e5378 Add role management UI tests
Add Playwright-based UI tests for the Roles management page including:
- RolesPage page object with navigation, add, delete, and visibility methods
- Tests verifying Administrator role visibility
- Tests for adding and deleting custom roles
- Test confirming Administrator role cannot be deleted

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 08:25:44 +11:00
Aaron Powell
5e3d740b83 Refactor UI tests to use WebApplicationFactory instead of dotnet run
Replace process-based server management (ServerManager) with ASP.NET Core's
WebApplicationFactory<Program> for proper integration testing:

- Add UITestApplicationFactory with dual-host pattern (real Kestrel + dummy TestServer)
  to work around .NET 9's hard-cast to TestServer in WebApplicationFactory
- Use in-memory EF Core database with dynamic port binding (IPAddress.Loopback:0)
- Stub IVersionProvider and IGitHubService to avoid external dependencies
- Programmatically create admin user in ConfiguredServerFixture via service layer
- Remove ServerManager.cs (no longer needed)
- Skip CompleteWizardAndLogin test (requires real DB file I/O)
- 18 tests pass, 1 skipped, ~28s runtime (down from ~101s)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 08:25:44 +11:00
Aaron Powell
94cfd6fe18 Spike: Playwright UI automated tests for server application
Add LANCommander.Server.UI.Tests project with 19 Playwright-based UI tests
covering three key scenarios:

- First-time setup flow (4 tests): Fresh server redirect, setup wizard steps,
  database provider selection, and complete wizard-to-login flow
- Login flow (5 tests): Unauthenticated redirect, page elements, valid/invalid
  credentials, and empty credential handling
- Admin navigation (10 tests): Dashboard, sidebar menu, Games, Redistributables,
  Tools, Servers, Issues, Files, Settings pages, and Settings submenu items

Infrastructure includes:
- ServerManager: Manages server process lifecycle with data directory backup/restore
- PlaywrightFixture: Browser lifecycle management (headless Chromium)
- ConfiguredServerFixture: Shared xUnit class fixture for tests needing a
  configured server (avoids restarting server per test)
- Page objects: LoginPage, FirstTimeSetupPage, AdminDashboardPage

Key technical decisions:
- Uses IClassFixture<T> pattern to share server instances across test classes
- Disables parallel execution (tests share port 1337)
- Uses element-based waits instead of URL-based waits for Blazor SSR reliability
- Uses role-based selectors for AntDesign components (no standard label/for)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 08:25:44 +11:00
397 changed files with 75634 additions and 2148 deletions

View file

@ -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" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>${APP_NAME}</string>
<key>CFBundleDisplayName</key>
<string>${APP_NAME}</string>
<key>CFBundleIdentifier</key>
<string>${BUNDLE_ID}</string>
<key>CFBundleExecutable</key>
<string>${EXECUTABLE}</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleShortVersionString</key>
<string>${NUMERIC}</string>
<key>CFBundleVersion</key>
<string>${SEMVER}</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
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 = @{

View file

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

View file

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

View file

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

View file

@ -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" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>${APP_NAME}</string>
<key>CFBundleDisplayName</key>
<string>${APP_NAME}</string>
<key>CFBundleIdentifier</key>
<string>${BUNDLE_ID}</string>
<key>CFBundleExecutable</key>
<string>${EXECUTABLE}</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleShortVersionString</key>
<string>${NUMERIC}</string>
<key>CFBundleVersion</key>
<string>${SEMVER}</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
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 = @{

View file

@ -13,7 +13,7 @@
<PackageVersion Include="LiveChartsCore.SkiaSharpView.Avalonia" Version="2.0.0-rc5.4" />
<PackageVersion Include="Markdig" Version="0.44.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Configuration" Version="9.0.8" />
<PackageVersion Include="Notify.NET" Version="1.0.2" />
<PackageVersion Include="Notify.NET" Version="1.1.0" />
<PackageVersion Include="Svrooij.PowerShell.DI" Version="1.3.4" />
</ItemGroup>
<ItemGroup Label="AutoMapper">
@ -69,7 +69,9 @@
<PackageVersion Include="Microsoft.CodeAnalysis.Workspaces.Common" Version="4.12.0" />
</ItemGroup>
<ItemGroup Label="Testing">
<PackageVersion Include="bunit" Version="1.40.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.Playwright" Version="1.51.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.3" />
<PackageVersion Include="Microsoft.TypeScript.MSBuild" Version="5.7.1" />
@ -97,7 +99,7 @@
<PackageVersion Include="RestSharp" Version="112.1.0" />
<PackageVersion Include="rix0rrr.BeaconLib" Version="1.0.2" />
<PackageVersion Include="Semver" Version="3.0.0" />
<PackageVersion Include="SharpCompress" Version="0.47.0" />
<PackageVersion Include="SharpCompress" Version="0.50.0" />
<PackageVersion Include="SteamWebAPI2" Version="4.4.1" />
</ItemGroup>
<ItemGroup Label="Entity Framework Core">

View file

@ -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
</details>
### 2.1.3
<details>
<summary>View 2.1.3 patch notes</summary>
#### 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`
<ReleaseDownloads release="v2.1.3" />
</details>
### 2.1.4
<details>
<summary>View 2.1.4 patch notes</summary>
#### 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.
<ReleaseDownloads release="v2.1.4" />
</details>
### 2.1.5
<details>
<summary>View 2.1.5 patch notes</summary>
#### 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.
<ReleaseDownloads release="v2.1.5" />
</details>
### 2.1.6
<details>
<summary>View 2.1.6 patch notes</summary>
#### 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).
<ReleaseDownloads release="v2.1.6" />
</details>
### 2.1.7
<details>
<summary>View 2.1.7 patch notes</summary>
#### 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.
<ReleaseDownloads release="v2.1.7" />
</details>
### 2.1.8
<details>
<summary>View 2.1.8 patch notes</summary>
#### 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.
<ReleaseDownloads release="v2.1.8" />
</details>
### 2.1.9
<details>
<summary>View 2.1.9 patch notes</summary>
#### 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.
<ReleaseDownloads release="v2.1.9" />
</details>
## Downloads
<ReleaseDownloads release="v2.1.9" />
<details>
<summary>View 2.1.8 downloads</summary>
<ReleaseDownloads release="v2.1.8" />
</details>
<details>
<summary>View 2.1.7 downloads</summary>
<ReleaseDownloads release="v2.1.7" />
</details>
<details>
<summary>View 2.1.6 downloads</summary>
<ReleaseDownloads release="v2.1.6" />
</details>
<details>
<summary>View 2.1.5 downloads</summary>
<ReleaseDownloads release="v2.1.5" />
</details>
<details>
<summary>View 2.1.4 downloads</summary>
<ReleaseDownloads release="v2.1.4" />
</details>
<details>
<summary>View 2.1.3 downloads</summary>
<ReleaseDownloads release="v2.1.3" />
</details>
<details>
<summary>View 2.1.2 downloads</summary>
<ReleaseDownloads release="v2.1.2" />
</details>
<details>
<summary>View 2.1.1 downloads</summary>
@ -388,4 +675,4 @@ When a game has no update available, the launcher will now refresh the manifest
## Contributors
<ContributorGrid from="v2.0.2" to="v2.1.2" />
<ContributorGrid from="v2.0.2" to="v2.1.9" />

View file

@ -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
<ReleaseDownloads release="v2.1.3" />
## Contributors
<ContributorGrid from="v2.1.2" to="v2.1.3" />

View file

@ -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
<ReleaseDownloads release="v2.1.4" />
## Contributors
<ContributorGrid from="v2.1.3" to="v2.1.4" />

View file

@ -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
<ReleaseDownloads release="v2.1.5" />
## Contributors
<ContributorGrid from="v2.1.4" to="v2.1.5" />

View file

@ -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
<ReleaseDownloads release="v2.1.6" />
## Contributors
<ContributorGrid from="v2.1.5" to="v2.1.6" />

View file

@ -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
<ReleaseDownloads release="v2.1.7" />
## Contributors
<ContributorGrid from="v2.1.6" to="v2.1.7" />

View file

@ -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
<ReleaseDownloads release="v2.1.8" />
## Contributors
<ContributorGrid from="v2.1.7" to="v2.1.8" />

View file

@ -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
<ReleaseDownloads release="v2.1.9" />
## Contributors
<ContributorGrid from="v2.1.8" to="v2.1.9" />

View file

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

View file

@ -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)://<ServerAddress>/SignInOIDC```

View file

@ -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)://<ServerAddress>/SignInOIDC` |
| OAuth2 | `http(s)://<ServerAddress>/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.

View file

@ -139,6 +139,14 @@ namespace LANCommander.Launcher.Data
gr => gr.HasOne<Game>().WithMany().HasForeignKey("GameId")
);
builder.Entity<Game>()
.HasMany(g => g.Tools)
.WithMany(t => t.Games)
.UsingEntity<GameTool>(
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<Game>()
.HasMany(g => g.DependentGames)
.WithOne(g => g.BaseGame)

View file

@ -0,0 +1,985 @@
// <auto-generated />
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
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.9");
modelBuilder.Entity("CategoryGame", b =>
{
b.Property<Guid>("CategoriesId")
.HasColumnType("TEXT");
b.Property<Guid>("GamesId")
.HasColumnType("TEXT");
b.HasKey("CategoriesId", "GamesId");
b.HasIndex("GamesId");
b.ToTable("CategoryGame");
});
modelBuilder.Entity("CollectionGame", b =>
{
b.Property<Guid>("CollectionId")
.HasColumnType("TEXT");
b.Property<Guid>("GameId")
.HasColumnType("TEXT");
b.HasKey("CollectionId", "GameId");
b.HasIndex("GameId");
b.ToTable("CollectionGame");
});
modelBuilder.Entity("GameDeveloper", b =>
{
b.Property<Guid>("DeveloperId")
.HasColumnType("TEXT");
b.Property<Guid>("GameId")
.HasColumnType("TEXT");
b.HasKey("DeveloperId", "GameId");
b.HasIndex("GameId");
b.ToTable("GameDeveloper");
});
modelBuilder.Entity("GameGenre", b =>
{
b.Property<Guid>("GamesId")
.HasColumnType("TEXT");
b.Property<Guid>("GenresId")
.HasColumnType("TEXT");
b.HasKey("GamesId", "GenresId");
b.HasIndex("GenresId");
b.ToTable("GameGenre");
});
modelBuilder.Entity("GamePlatform", b =>
{
b.Property<Guid>("GamesId")
.HasColumnType("TEXT");
b.Property<Guid>("PlatformsId")
.HasColumnType("TEXT");
b.HasKey("GamesId", "PlatformsId");
b.HasIndex("PlatformsId");
b.ToTable("GamePlatform");
});
modelBuilder.Entity("GamePublisher", b =>
{
b.Property<Guid>("GameId")
.HasColumnType("TEXT");
b.Property<Guid>("PublisherId")
.HasColumnType("TEXT");
b.HasKey("GameId", "PublisherId");
b.HasIndex("PublisherId");
b.ToTable("GamePublisher");
});
modelBuilder.Entity("GameRedistributable", b =>
{
b.Property<Guid>("GameId")
.HasColumnType("TEXT");
b.Property<Guid>("RedistributableId")
.HasColumnType("TEXT");
b.HasKey("GameId", "RedistributableId");
b.HasIndex("RedistributableId");
b.ToTable("GameRedistributable");
});
modelBuilder.Entity("GameTag", b =>
{
b.Property<Guid>("GamesId")
.HasColumnType("TEXT");
b.Property<Guid>("TagsId")
.HasColumnType("TEXT");
b.HasKey("GamesId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("GameTag");
});
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Category", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedOn")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("ParentId")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ParentId");
b.ToTable("Categories");
});
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Collection", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedOn")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Collections");
});
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Company", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedOn")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Companies");
});
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Engine", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedOn")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Engines");
});
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Game", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("BaseGameId")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<Guid?>("EngineId")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedOn")
.HasColumnType("TEXT");
b.Property<string>("InstallDirectory")
.HasColumnType("TEXT");
b.Property<bool>("Installed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("InstalledOn")
.HasColumnType("TEXT");
b.Property<string>("InstalledVersion")
.HasColumnType("TEXT");
b.Property<string>("LatestVersion")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<DateTime?>("ReleasedOn")
.HasColumnType("TEXT");
b.Property<bool>("Singleplayer")
.HasColumnType("INTEGER");
b.Property<string>("SortTitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<string>("ExternalId")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid?>("GameId")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedOn")
.HasColumnType("TEXT");
b.Property<string>("Provider")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GameId");
b.ToTable("GameExternalIds");
});
modelBuilder.Entity("LANCommander.Launcher.Data.Models.GameTool", b =>
{
b.Property<Guid>("GameId")
.HasColumnType("TEXT");
b.Property<Guid>("ToolId")
.HasColumnType("TEXT");
b.Property<string>("InstallDirectory")
.HasColumnType("TEXT");
b.Property<bool>("Installed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("InstalledOn")
.HasColumnType("TEXT");
b.Property<string>("InstalledVersion")
.HasColumnType("TEXT");
b.HasKey("GameId", "ToolId");
b.HasIndex("ToolId");
b.ToTable("GameTool");
});
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Genre", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedOn")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Genres");
});
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Library", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedOn")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("Libraries");
});
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Media", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Crc32")
.IsRequired()
.HasMaxLength(8)
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<Guid>("FileId")
.HasColumnType("TEXT");
b.Property<Guid?>("GameId")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedOn")
.HasColumnType("TEXT");
b.Property<string>("MimeType")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("SourceUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<Guid?>("GameId")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedOn")
.HasColumnType("TEXT");
b.Property<int>("MaxPlayers")
.HasColumnType("INTEGER");
b.Property<int>("MinPlayers")
.HasColumnType("INTEGER");
b.Property<int>("NetworkProtocol")
.HasColumnType("INTEGER");
b.Property<int>("Spectators")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GameId");
b.ToTable("MultiplayerModes");
});
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Platform", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedOn")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Platforms");
});
modelBuilder.Entity("LANCommander.Launcher.Data.Models.PlaySession", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<DateTime?>("End")
.HasColumnType("TEXT");
b.Property<Guid?>("GameId")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedOn")
.HasColumnType("TEXT");
b.Property<DateTime?>("Start")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("GameId");
b.ToTable("PlaySessions");
});
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Redistributable", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedOn")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Redistributables");
});
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedOn")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Tags");
});
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Tool", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedOn")
.HasColumnType("TEXT");
b.Property<string>("LatestVersion")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Tools");
});
modelBuilder.Entity("LANCommander.Launcher.Data.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Alias")
.HasColumnType("TEXT");
b.Property<DateTime>("CreatedOn")
.HasColumnType("TEXT");
b.Property<DateTime>("ImportedOn")
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.Property<string>("UserName")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("LibraryGame", b =>
{
b.Property<Guid>("GameId")
.HasColumnType("TEXT");
b.Property<Guid>("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
}
}
}

View file

@ -0,0 +1,199 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace LANCommander.Launcher.Data.Migrations
{
/// <inheritdoc />
public partial class AddPerGameToolInstallState : Migration
{
/// <inheritdoc />
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<string>(
name: "InstallDirectory",
table: "GameTool",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "Installed",
table: "GameTool",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "InstalledOn",
table: "GameTool",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
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);
}
/// <inheritdoc />
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<string>(
name: "InstallDirectory",
table: "Tools",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "Installed",
table: "Tools",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "InstalledOn",
table: "Tools",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
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);
}
}
}

View file

@ -137,21 +137,6 @@ namespace LANCommander.Launcher.Data.Migrations
b.ToTable("GameTag");
});
modelBuilder.Entity("GameTool", b =>
{
b.Property<Guid>("GamesId")
.HasColumnType("TEXT");
b.Property<Guid>("ToolsId")
.HasColumnType("TEXT");
b.HasKey("GamesId", "ToolsId");
b.HasIndex("ToolsId");
b.ToTable("GameTool");
});
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Category", b =>
{
b.Property<Guid>("Id")
@ -355,6 +340,33 @@ namespace LANCommander.Launcher.Data.Migrations
b.ToTable("GameExternalIds");
});
modelBuilder.Entity("LANCommander.Launcher.Data.Models.GameTool", b =>
{
b.Property<Guid>("GameId")
.HasColumnType("TEXT");
b.Property<Guid>("ToolId")
.HasColumnType("TEXT");
b.Property<string>("InstallDirectory")
.HasColumnType("TEXT");
b.Property<bool>("Installed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("InstalledOn")
.HasColumnType("TEXT");
b.Property<string>("InstalledVersion")
.HasColumnType("TEXT");
b.HasKey("GameId", "ToolId");
b.HasIndex("ToolId");
b.ToTable("GameTool");
});
modelBuilder.Entity("LANCommander.Launcher.Data.Models.Genre", b =>
{
b.Property<Guid>("Id")
@ -632,18 +644,6 @@ namespace LANCommander.Launcher.Data.Migrations
b.Property<DateTime>("ImportedOn")
.HasColumnType("TEXT");
b.Property<string>("InstallDirectory")
.HasColumnType("TEXT");
b.Property<bool>("Installed")
.HasColumnType("INTEGER");
b.Property<DateTime?>("InstalledOn")
.HasColumnType("TEXT");
b.Property<string>("InstalledVersion")
.HasColumnType("TEXT");
b.Property<string>("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");

View file

@ -44,6 +44,7 @@ namespace LANCommander.Launcher.Data.Models
public virtual ICollection<Platform>? Platforms { get; set; } = new List<Platform>();
public virtual ICollection<Redistributable>? Redistributables { get; set; } = new List<Redistributable>();
public virtual ICollection<Tool>? Tools { get; set; } = new List<Tool>();
public virtual ICollection<GameTool> GameTools { get; set; } = new List<GameTool>();
public virtual ICollection<Media>? Media { get; set; } = new List<Media>();
public virtual ICollection<Collection> Collections { get; set; } = new List<Collection>();
public virtual ICollection<Game> DependentGames { get; set; } = new List<Game>();

View file

@ -0,0 +1,25 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace LANCommander.Launcher.Data.Models
{
/// <summary>
/// Join entity between <see cref="Game"/> and <see cref="Tool"/> 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.
/// </summary>
[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; }
}
}

View file

@ -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<Game>? Games { get; set; } = new List<Game>();
public virtual ICollection<GameTool> GameTools { get; set; } = new List<GameTool>();
}
}

View file

@ -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<Guid, string?> AddonVersions { get; set; }
public string Title { get; set; }
public string Version { get; set; }

View file

@ -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<ITokenProvider>();
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<AuthenticationService>.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<ImportService>.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<List<Guid>> 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]);
}
}

View file

@ -35,6 +35,8 @@ namespace LANCommander.Launcher.Services.Extensions
services.AddSingleton<KeepAliveService>();
#endregion
services.AddSingleton<ICurrentProcessInfo, CurrentProcessInfo>();
services.AddSingleton<IElevatedProcessLauncher, ElevatedProcessLauncher>();
services.AddSingleton<IScriptInterceptor, ElevatedScriptInterceptor>();
services.AddSingleton<ScriptDebugger>();
services.AddSingleton<IScriptDebugger>(sp =>

View file

@ -19,6 +19,8 @@ namespace LANCommander.Launcher.Services
PlaySessionService playSessionService,
ProfileClient profileClient,
GameClient gameClient,
ToolService toolService,
ToolClient toolClient,
IConnectionClient connectionClient,
IServiceProvider serviceProvider) : BaseDatabaseService<Game>(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);

View file

@ -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<bool> AddAsync(ImportItemInfo<Game> importItemInfo)

View file

@ -8,7 +8,6 @@ namespace LANCommander.Launcher.Services.Import.Importers;
public class ToolImporter(
ToolService toolService,
LibraryService libraryService,
ILogger<ToolImporter> logger) : BaseImporter<Tool>
{
public override async Task<ImportItemInfo<Tool>> 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<bool> AddAsync(ImportItemInfo<Tool> 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;
}

View file

@ -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<Guid> 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<Data.Models.Game>()
: 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();

View file

@ -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<Guid> _pendingNotificationRoots = new();
public InstallService(
ILogger<InstallService> 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<Guid>();
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))

View file

@ -11,6 +11,10 @@
<PackageReference Include="System.IdentityModel.Tokens.Jwt" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="LANCommander.Launcher.Services.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LANCommander.Launcher.Data\LANCommander.Launcher.Data.csproj" />
<ProjectReference Include="..\LANCommander.Launcher.Models\LANCommander.Launcher.Models.csproj" />

View file

@ -174,6 +174,16 @@ namespace LANCommander.Launcher.Services
.AnyAsync(g => g.Id == gameId && g.Libraries.Any(l => l.UserId == userId));
}
public async Task<HashSet<Guid>> 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<IEnumerable<ListItem>> GetItemsAsync()
{
Items.Clear();

View file

@ -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";
}
}
}

View file

@ -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();
}
}

View file

@ -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<bool> 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<SDK.Models.Manifest.Game>("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;
}
}
}

View file

@ -0,0 +1,21 @@
namespace LANCommander.Launcher.Services;
/// <summary>
/// Exposes information about the currently running launcher process that the
/// <see cref="ElevatedScriptInterceptor"/> 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.
/// </summary>
public interface ICurrentProcessInfo
{
/// <summary>
/// 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.
/// </summary>
string ExecutablePath { get; }
/// <summary>
/// True if the current process is already running with administrator/root privileges.
/// </summary>
bool IsElevated { get; }
}

View file

@ -0,0 +1,32 @@
using System.Threading.Tasks;
namespace LANCommander.Launcher.Services;
/// <summary>
/// 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.
/// </summary>
public class ElevatedProcessRequest
{
/// <summary>The launcher executable to invoke elevated.</summary>
public required string FileName { get; init; }
/// <summary>The formatted command line (RunScript verb + options) passed to the elevated process.</summary>
public required string Arguments { get; init; }
/// <summary>The working directory the elevated script should run in.</summary>
public string? WorkingDirectory { get; init; }
}
/// <summary>
/// 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.
/// </summary>
public interface IElevatedProcessLauncher
{
/// <summary>
/// Starts the elevated process described by <paramref name="request"/> and completes only once
/// that process has exited.
/// </summary>
Task LaunchAndWaitAsync(ElevatedProcessRequest request);
}

View file

@ -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<ToolService> logger,
DatabaseContext dbContext) : BaseDatabaseService<Tool>(dbContext, logger)
{
/// <summary>
/// Returns the install-state join rows for every tool currently installed for the given game.
/// The related <see cref="Tool"/> is eagerly loaded.
/// </summary>
public async Task<List<GameTool>> GetInstalledToolsForGameAsync(Guid gameId)
{
return await Context.Set<GameTool>()
.Include(gt => gt.Tool)
.Where(gt => gt.GameId == gameId && gt.Installed)
.ToListAsync();
}
/// <summary>
/// Returns the install-state join rows for every game the given tool is installed for.
/// </summary>
public async Task<List<GameTool>> GetInstalledGameToolsAsync(Guid toolId)
{
return await Context.Set<GameTool>()
.Where(gt => gt.ToolId == toolId && gt.Installed)
.ToListAsync();
}
public async Task<bool> IsToolInstalledForGameAsync(Guid gameId, Guid toolId)
{
return await Context.Set<GameTool>()
.AnyAsync(gt => gt.GameId == gameId && gt.ToolId == toolId && gt.Installed);
}
/// <summary>
/// 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.
/// </summary>
public async Task SetToolInstalledAsync(Guid gameId, Guid toolId, string installDirectory, string version)
{
var gameTool = await Context.Set<GameTool>()
.FirstOrDefaultAsync(gt => gt.GameId == gameId && gt.ToolId == toolId);
if (gameTool == null)
{
gameTool = new GameTool { GameId = gameId, ToolId = toolId };
await Context.Set<GameTool>().AddAsync(gameTool);
}
gameTool.Installed = true;
gameTool.InstallDirectory = installDirectory;
gameTool.InstalledVersion = version;
gameTool.InstalledOn ??= DateTime.Now;
await Context.SaveChangesAsync();
}
/// <summary>
/// Clears the install state for a tool on a specific game without removing the game/tool
/// association. Other games keep their own install state.
/// </summary>
public async Task SetToolUninstalledAsync(Guid gameId, Guid toolId)
{
var gameTool = await Context.Set<GameTool>()
.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();
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 7.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 384 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 761 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 758 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Before After
Before After

View file

@ -1,8 +1,16 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="using:LANCommander.Launcher.Converters"
x:Class="LANCommander.Launcher.Tests.TestApp"
RequestedThemeVariant="Dark">
<Application.Styles>
<FluentTheme />
</Application.Styles>
<!-- Converters that views resolve from the application's resource scope.
The real App.axaml declares these globally; mirror only what the views
under test require so unstyled baselines stay otherwise unchanged. -->
<Application.Resources>
<converters:MultiplyConverter x:Key="MultiplyConverter" />
</Application.Resources>
</Application>

View file

@ -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;
/// <summary>
/// Verifies the admin-elevation path for launcher scripts. When a script is flagged
/// <c>#Requires -RunAsAdministrator</c> 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.
/// </summary>
public class ElevatedScriptInterceptorTests
{
private static PowerShellScript CreateScript(ScriptType type)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton<ISettingsProvider, FakeSettingsProvider>();
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<ElevatedProcessRequest> Requests { get; } = new();
public int LaunchCount => Requests.Count;
public bool ThrowOnLaunch { get; init; }
/// <summary>Set once the (awaited) launch has fully completed. Proves the caller waited.</summary>
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<SdkSettings> patch) => patch(CurrentValue);
}
}

View file

@ -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<INavigationService, NavigationService>()
.BuildServiceProvider();
// ---------------------------------------------------------------------------

View file

@ -25,6 +25,29 @@
<Setter Property="FontSize" Value="16" />
</Style>
<!-- System tray menu. Avalonia's Win32 tray popup is a Window named
"AvaloniaTrayPopupRoot_<tooltip>"; our tooltip is "LANCommander"
(see TrayIconExtensions). The menu mixes icon items (recently played
games) with text-only items (Depot/Library/Settings/Exit). These styles
are scoped to that window so the in-app menus are unaffected. -->
<!-- Symmetric padding so item text is vertically centered. The Fluent default
(11,4,11,7) is bottom-heavy, which leaves the text a couple px above center. -->
<Style Selector="Window#AvaloniaTrayPopupRoot_LANCommander MenuItem">
<Setter Property="Padding" Value="11,6,11,6" />
</Style>
<!-- Text-only items have no icon, but Fluent reserves a shared icon column so
every item aligns. That column can't be removed from a style (the template
sets the shared-size scope as a local value), so instead pull icon-less
items' text left by the icon column width (icon 16 + 12 right margin = 28)
so they sit flush-left at the games' icon edge, not at the game titles. -->
<Style Selector="Window#AvaloniaTrayPopupRoot_LANCommander MenuItem:not(:icon) /template/ ContentPresenter#PART_HeaderPresenter">
<Setter Property="Margin" Value="-28,0,0,0" />
</Style>
<!-- Make the section separators span the full menu width. -->
<Style Selector="Window#AvaloniaTrayPopupRoot_LANCommander Separator">
<Setter Property="Margin" Value="0,4,0,4" />
</Style>
<!-- Offset the vertical scrollbar so it doesn't overlap the floating titlebar -->
<Style Selector="ScrollViewer.TitlebarOffset /template/ ScrollBar#PART_VerticalScrollBar">
<Setter Property="Margin" Value="0,40,0,0" />

View file

@ -8,6 +8,7 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Data.Core.Plugins;
using Avalonia.Markup.Xaml;
using LANCommander.Launcher.Input;
using LANCommander.Launcher.Helpers;
using LANCommander.Launcher.Services;
using LANCommander.Launcher.ViewModels;
using LANCommander.Launcher.Views;
@ -80,15 +81,30 @@ public partial class App : Application
};
desktop.MainWindow = mainWindow;
mainWindow.Show();
// Initialize taskbar progress service with the window handle
mainWindow.Opened += (_, _) =>
// Bind the taskbar progress indicator to the main window handle. This must be
// wired BEFORE Show(): on Windows, Show() raises Opened synchronously, so a
// handler attached afterwards would never fire and the progress bar would stay
// bound to Notify.NET's default GetConsoleWindow() target instead of the app.
void BindTaskbarProgress()
{
var hwnd = mainWindow.TryGetPlatformHandle()?.Handle ?? IntPtr.Zero;
if (hwnd != IntPtr.Zero)
Services.GetRequiredService<TaskbarProgressService>().Initialize(hwnd);
};
}
mainWindow.Opened += (_, _) => BindTaskbarProgress();
mainWindow.Show();
// If Opened already fired synchronously during Show(), the handler above missed
// it; bind now since the handle is available once the window is shown.
BindTaskbarProgress();
// System tray icon: the main window hides to tray on close, so the tray
// provides navigation and an exit path. See TrayIconExtensions.
var trayIcon = mainWindow.CreateTrayIcon(mainViewModel);
TrayIcon.SetIcons(this, new TrayIcons { trayIcon });
// Single-instance pipe server: forward notification-click navigations
var singleInstance = Services.GetRequiredService<SingleInstanceService>();
@ -96,10 +112,16 @@ public partial class App : Application
singleInstance.StartServer();
singleInstance.NavigateToGameRequested += async (_, gameId) =>
{
mainWindow.Activate();
// The window may be hidden in the tray; surface it on the UI thread
// before navigating (this fires from the named-pipe listener thread).
Avalonia.Threading.Dispatcher.UIThread.Post(mainWindow.RestoreFromTray);
var shell = Services.GetRequiredService<MainWindowViewModel>().ShellViewModel;
await shell.NavigateToGameByIdAsync(gameId).ConfigureAwait(false);
};
// A second launch (e.g. the user forgot it was hiding in the tray) asks the
// running instance to restore its window instead of opening a duplicate.
singleInstance.RestoreRequested += (_, _) =>
Avalonia.Threading.Dispatcher.UIThread.Post(mainWindow.RestoreFromTray);
mainWindow.Closed += (_, _) => singleInstance.Dispose();
// Start gamepad navigation (gracefully disabled if SDL3 is absent)
@ -235,6 +257,10 @@ public partial class App : Application
opts.AppName = "LANCommander";
opts.AppUserModelId = "LANCommander.Launcher";
});
services.AddTaskbarProgress(opts =>
{
opts.DesktopFileId = "LANCommander.Launcher";
});
services.AddSingleton<NotificationService>();
services.AddSingleton<TaskbarProgressService>();
services.AddSingleton<SingleInstanceService>();

View file

@ -92,7 +92,7 @@
<!-- ── Notifications ─────────────────────────────────────────────────── -->
<sys:String x:Key="InstallComplete">Installation Complete!</sys:String>
<sys:String x:Key="InstallationFailed">Installation Failed!</sys:String>
<sys:String x:Key="InstallFailed">Installation Failed!</sys:String>
<sys:String x:Key="ViewInLibrary">View in Library</sys:String>
<sys:String x:Key="Play">Play</sys:String>
@ -101,6 +101,7 @@
<sys:String x:Key="Complete">Complete</sys:String>
<sys:String x:Key="TransferSpeed">Transfer Speed (MB/s)</sys:String>
<sys:String x:Key="TransferSpeedHistory">Transfer Speed History (MB/s)</sys:String>
<sys:String x:Key="TimeRemaining">{0} remaining</sys:String>
<!-- ── Install Tasks ─────────────────────────────────────────────────── -->
<sys:String x:Key="TaskVerifyFiles">Verify local files</sys:String>
@ -114,6 +115,24 @@
<sys:String x:Key="TaskDownloadManuals">Download manuals</sys:String>
<sys:String x:Key="TaskDownloadRedist">Download {0}</sys:String>
<sys:String x:Key="TaskInstallRedist">Install {0}</sys:String>
<!-- ── Install Status ────────────────────────────────────────────────── -->
<sys:String x:Key="InstallStatusComplete">Complete</sys:String>
<sys:String x:Key="InstallStatusFailed">Failed</sys:String>
<sys:String x:Key="InstallStatusQueued">Queued</sys:String>
<!-- ── Play Stats ────────────────────────────────────────────────────── -->
<sys:String x:Key="PlayStatNone">None</sys:String>
<sys:String x:Key="PlayTimeMinutes">{0} minutes</sys:String>
<sys:String x:Key="PlayTimeHours">{0} hours</sys:String>
<sys:String x:Key="LastPlayedNever">Never</sys:String>
<sys:String x:Key="LastPlayedJustNow">Just now</sys:String>
<sys:String x:Key="LastPlayedMinuteAgo">{0} minute ago</sys:String>
<sys:String x:Key="LastPlayedMinutesAgo">{0} minutes ago</sys:String>
<sys:String x:Key="LastPlayedHourAgo">{0} hour ago</sys:String>
<sys:String x:Key="LastPlayedHoursAgo">{0} hours ago</sys:String>
<sys:String x:Key="LastPlayedDayAgo">{0} day ago</sys:String>
<sys:String x:Key="LastPlayedDaysAgo">{0} days ago</sys:String>
<!-- ── Game Detail ───────────────────────────────────────────────────── -->
<sys:String x:Key="Singleplayer">Singleplayer</sys:String>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

View file

@ -0,0 +1,109 @@
using System;
using System.Threading;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media.Imaging;
using Avalonia.Threading;
using LANCommander.Launcher.Helpers;
namespace LANCommander.Launcher.Controls;
/// <summary>
/// Attached properties that asynchronously populate an <see cref="Image.Source"/> from a
/// local file path or an http(s) URL, routing through <see cref="RemoteImageCache"/> so
/// bitmaps are fetched/decoded off the UI thread and reused across the app.
///
/// Usage: <c>&lt;Image controls:AsyncImage.Source="{Binding HeroPath}" controls:AsyncImage.DecodeWidth="480" /&gt;</c>
///
/// Replaces the file-only <c>FilePathToBitmapConverter</c> where images may live on the server.
/// </summary>
public class AsyncImage : AvaloniaObject
{
public static readonly AttachedProperty<string?> SourceProperty =
AvaloniaProperty.RegisterAttached<AsyncImage, Image, string?>("Source");
public static readonly AttachedProperty<int> DecodeWidthProperty =
AvaloniaProperty.RegisterAttached<AsyncImage, Image, int>("DecodeWidth");
public static readonly AttachedProperty<int> DecodeHeightProperty =
AvaloniaProperty.RegisterAttached<AsyncImage, Image, int>("DecodeHeight");
// Per-Image cancellation token for the in-flight load, so rapid source changes
// (e.g. carousel container recycling) don't race to set a stale bitmap.
private static readonly AttachedProperty<CancellationTokenSource?> LoadCtsProperty =
AvaloniaProperty.RegisterAttached<AsyncImage, Image, CancellationTokenSource?>("LoadCts");
static AsyncImage()
{
SourceProperty.Changed.AddClassHandler<Image>((image, _) => Reload(image));
DecodeWidthProperty.Changed.AddClassHandler<Image>((image, _) => Reload(image));
DecodeHeightProperty.Changed.AddClassHandler<Image>((image, _) => Reload(image));
}
public static string? GetSource(Image image) => image.GetValue(SourceProperty);
public static void SetSource(Image image, string? value) => image.SetValue(SourceProperty, value);
public static int GetDecodeWidth(Image image) => image.GetValue(DecodeWidthProperty);
public static void SetDecodeWidth(Image image, int value) => image.SetValue(DecodeWidthProperty, value);
public static int GetDecodeHeight(Image image) => image.GetValue(DecodeHeightProperty);
public static void SetDecodeHeight(Image image, int value) => image.SetValue(DecodeHeightProperty, value);
private static void Reload(Image image)
{
var previous = image.GetValue(LoadCtsProperty);
previous?.Cancel();
previous?.Dispose();
image.SetValue(LoadCtsProperty, null);
var source = GetSource(image);
if (string.IsNullOrEmpty(source))
{
image.Source = null;
return;
}
var width = GetDecodeWidth(image);
var height = GetDecodeHeight(image);
// Instant path: already decoded, avoid a flash of empty space on scroll-back.
if (RemoteImageCache.TryGet(source, width, height, out var cached))
{
image.Source = cached;
return;
}
image.Source = null;
var cts = new CancellationTokenSource();
image.SetValue(LoadCtsProperty, cts);
LoadAsync(image, source, width, height, cts);
}
private static async void LoadAsync(Image image, string source, int width, int height, CancellationTokenSource cts)
{
try
{
var bitmap = await RemoteImageCache.LoadAsync(source, width, height, cts.Token);
if (bitmap == null || cts.IsCancellationRequested)
return;
// Apply at Background priority so a burst of image completions (the whole
// depot realizes at once — carousels aren't virtualized) yields to scroll
// input and rendering instead of forcing a layout pass ahead of them.
await Dispatcher.UIThread.InvokeAsync(() =>
{
// Only apply if this load is still the current one for this image.
if (!cts.IsCancellationRequested && GetSource(image) == source)
image.Source = bitmap;
}, DispatcherPriority.Background);
}
catch
{
// Network/decoding failures leave the image blank; visibility is driven by the path binding.
}
}
}

View file

@ -6,15 +6,28 @@ using Avalonia.Controls.Primitives;
using Avalonia.Controls.Templates;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Media.Transformation;
using Avalonia.Threading;
using Avalonia.VisualTree;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Windows.Input;
namespace LANCommander.Launcher.Controls;
/// <summary>
/// Implemented by carousel item content (e.g. inline video players) that should
/// only run while on-screen. The carousel toggles activity as items scroll in
/// and out of view to avoid wasting CPU on off-screen playback.
/// </summary>
public interface ICarouselPlaybackItem
{
void SetCarouselActive(bool active);
}
public class CarouselControl : TemplatedControl
{
public static readonly StyledProperty<IEnumerable?> ItemsSourceProperty =
@ -124,6 +137,9 @@ public class CarouselControl : TemplatedControl
private Transitions? _transitions;
private RectangleGeometry? _clipGeometry;
private int _virtualIndex = 0;
private double _currentOffsetX = 0;
private WindowBase? _hostWindow;
private bool _windowActive = true;
static CarouselControl()
{
@ -142,6 +158,13 @@ public class CarouselControl : TemplatedControl
_leftButton = e.NameScope.Find<Button>("PART_LeftButton");
_rightButton = e.NameScope.Find<Button>("PART_RightButton");
if (_itemsControl != null)
{
// Items load progressively (e.g. media popping in), so recompute which
// items are on-screen whenever a container is realized.
_itemsControl.ContainerPrepared += (_, _) => UpdateItemVisibility();
}
if (_clipPanel != null)
{
_clipPanel.SizeChanged += (_, _) => { UpdateClipGeometry(); SnapToCurrentIndex(); };
@ -150,11 +173,13 @@ public class CarouselControl : TemplatedControl
if (_itemsContainer != null)
{
// Scroll via a composited RenderTransform rather than Margin so the
// animation doesn't trigger a layout pass over every item each frame.
_transitions = new Transitions
{
new ThicknessTransition
new TransformOperationsTransition
{
Property = MarginProperty,
Property = RenderTransformProperty,
Duration = TimeSpan.FromMilliseconds(350),
Easing = new CubicEaseOut()
}
@ -171,6 +196,47 @@ public class CarouselControl : TemplatedControl
RebuildInternalSource();
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
// Pause playback items while the window is in the background — there's no
// point decoding video the user can't see.
_hostWindow = TopLevel.GetTopLevel(this) as WindowBase;
if (_hostWindow != null)
{
_windowActive = _hostWindow.IsActive;
_hostWindow.Activated += OnHostWindowActivated;
_hostWindow.Deactivated += OnHostWindowDeactivated;
}
UpdateItemVisibility();
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
if (_hostWindow != null)
{
_hostWindow.Activated -= OnHostWindowActivated;
_hostWindow.Deactivated -= OnHostWindowDeactivated;
_hostWindow = null;
}
base.OnDetachedFromVisualTree(e);
}
private void OnHostWindowActivated(object? sender, EventArgs e)
{
_windowActive = true;
UpdateItemVisibility();
}
private void OnHostWindowDeactivated(object? sender, EventArgs e)
{
_windowActive = false;
UpdateItemVisibility();
}
/// <summary>
/// When a child item gains focus (e.g. via gamepad), scroll to keep it visible.
/// </summary>
@ -361,18 +427,67 @@ public class CarouselControl : TemplatedControl
}
}
private void ScrollAnimated(double leftMargin)
private void ScrollAnimated(double offsetX)
{
if (_itemsContainer == null) return;
_itemsContainer.Margin = new Thickness(leftMargin, 0, 0, 0);
_currentOffsetX = offsetX;
_itemsContainer.RenderTransform = TranslateX(offsetX);
UpdateItemVisibility();
}
private void ScrollInstant(double leftMargin)
private void ScrollInstant(double offsetX)
{
if (_itemsContainer == null) return;
_currentOffsetX = offsetX;
_itemsContainer.Transitions = null;
_itemsContainer.Margin = new Thickness(leftMargin, 0, 0, 0);
_itemsContainer.RenderTransform = TranslateX(offsetX);
_itemsContainer.Transitions = _transitions;
UpdateItemVisibility();
}
private static ITransform TranslateX(double x) =>
TransformOperations.Parse(
$"translateX({x.ToString("F3", CultureInfo.InvariantCulture)}px)");
/// <summary>
/// Activates the carousel items whose horizontal span currently intersects the
/// viewport (and only while the host window is active) and deactivates the rest,
/// so off-screen or background playback items (e.g. inline videos) stop consuming
/// CPU. Deferred when containers aren't realized yet.
/// </summary>
private void UpdateItemVisibility()
{
if (_itemsControl == null || _clipPanel == null) return;
var count = _itemsControl.ItemCount;
if (count == 0) return;
var viewportWidth = _clipPanel.Bounds.Width;
if (viewportWidth <= 0)
{
// Layout hasn't run yet; retry once it has.
Dispatcher.UIThread.Post(UpdateItemVisibility, DispatcherPriority.Loaded);
return;
}
// _currentOffsetX is the (negative) translate applied to the container,
// which itself starts at -ItemOverflow (see Offset). The first visible
// pixel in item-space is therefore the inverse of that, minus the padding.
var scroll = -_currentOffsetX - ItemOverflow;
var step = ItemWidth + Gap;
for (var i = 0; i < count; i++)
{
var itemLeft = i * step;
var itemRight = itemLeft + ItemWidth;
var visible = _windowActive && itemRight > scroll && itemLeft < scroll + viewportWidth;
var container = _itemsControl.ContainerFromIndex(i);
if (container == null) continue;
foreach (var item in container.GetVisualDescendants().OfType<ICarouselPlaybackItem>())
item.SetCarouselActive(visible);
}
}
private void SnapToCurrentIndex()

View file

@ -0,0 +1,272 @@
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Runtime.CompilerServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Data.Converters;
using Avalonia.Input;
using Avalonia.Threading;
using Avalonia.VisualTree;
using LANCommander.Launcher.ViewModels;
using LANCommander.Launcher.ViewModels.Components;
namespace LANCommander.Launcher.Controls;
/// <summary>
/// Builds the consolidated, state-driven game menu and exposes it both as a flyout for the
/// action bar split buttons and as a right-click / gamepad context menu on game covers and
/// list rows.
///
/// The menu items bind against a <see cref="GameActionBarViewModel"/> via an explicit
/// <see cref="Binding.Source"/>, so the same definition works regardless of the host control's
/// own DataContext (a <see cref="GameItemViewModel"/> in grids and lists).
///
/// Attached-behavior usage: <c>controls:GameContextMenu.IsEnabled="True"</c> on a focusable
/// game cover or list row.
/// </summary>
public static class GameContextMenu
{
private static readonly IValueConverter Invert = new FuncValueConverter<bool, bool>(b => !b);
// Keeps each flyout's secondary-action subscription alive for the flyout's lifetime and
// allows callers to detach it explicitly when the flyout is discarded.
private static readonly ConditionalWeakTable<MenuFlyout, SecondaryActionsBinder> Binders = new();
// ── Attached behavior ───────────────────────────────────────────────────────────────────
public static readonly AttachedProperty<bool> IsEnabledProperty =
AvaloniaProperty.RegisterAttached<Control, bool>("IsEnabled", typeof(GameContextMenu));
static GameContextMenu()
{
IsEnabledProperty.Changed.AddClassHandler<Control>(OnIsEnabledChanged);
}
public static bool GetIsEnabled(Control control)
=> control.GetValue(IsEnabledProperty);
public static void SetIsEnabled(Control control, bool value)
=> control.SetValue(IsEnabledProperty, value);
private static void OnIsEnabledChanged(Control control, AvaloniaPropertyChangedEventArgs e)
{
control.ContextRequested -= OnContextRequested;
control.KeyDown -= OnKeyDown;
if (e.NewValue is true)
{
control.ContextRequested += OnContextRequested;
control.KeyDown += OnKeyDown;
}
}
private static void OnContextRequested(object? sender, ContextRequestedEventArgs e)
{
if (sender is Control control && OpenMenu(control, atPointer: true))
e.Handled = true;
}
private static void OnKeyDown(object? sender, KeyEventArgs e)
{
// The gamepad service maps the North (Y) button to Key.Apps on the focused element.
if (e.Key == Key.Apps && sender is Control control && OpenMenu(control, atPointer: false))
e.Handled = true;
}
private static bool OpenMenu(Control control, bool atPointer)
{
if (control.DataContext is not GameItemViewModel item)
return false;
if (App.Services is not { } serviceProvider)
return false;
var vm = new GameActionBarViewModel(serviceProvider);
// Load asynchronously, then show. Disposed on close so no timers leak (LoadForMenuAsync
// doesn't start any, but the VM owns disposable timers in other code paths).
_ = ShowAsync();
return true;
async System.Threading.Tasks.Task ShowAsync()
{
try
{
await vm.LoadForMenuAsync(item);
}
catch
{
vm.Dispose();
return;
}
var flyout = CreateFlyout(vm);
void OnLibraryChanged(object? s, System.EventArgs e) => RefreshHost(control);
void OnClosed(object? s, System.EventArgs e)
{
flyout.Closed -= OnClosed;
DetachBinder(flyout);
// Don't unsubscribe LibraryChanged/InstallRequested here: clicking a menu item
// closes the flyout immediately, but the backing command (e.g. Remove from
// Library) runs asynchronously and only raises these events after its await. We
// must stay subscribed so the host grid/list still refreshes once it completes.
// Disposing is safe (it only stops running-check timers, which the menu path
// never starts) and the VM is collected once the in-flight command finishes.
vm.Dispose();
}
vm.LibraryChanged += OnLibraryChanged;
vm.InstallRequested += OnLibraryChanged;
flyout.Closed += OnClosed;
flyout.ShowAt(control, atPointer);
}
}
/// <summary>
/// Refreshes the nearest ancestor games collection so library/install changes made from the
/// menu (add/remove, install, uninstall) are reflected in the grid or list immediately.
/// </summary>
private static void RefreshHost(Control control)
{
Visual? current = control;
while (current != null)
{
if (current is StyledElement { DataContext: GamesCollectionViewModel collection })
{
_ = collection.LoadGamesAsync();
return;
}
current = current.GetVisualParent();
}
}
// ── Flyout factory ──────────────────────────────────────────────────────────────────────
/// <summary>
/// Creates a consolidated game menu bound to the given view model. The caller owns the
/// returned flyout; call <see cref="DetachBinder"/> when it is discarded.
/// </summary>
public static MenuFlyout CreateFlyout(GameActionBarViewModel vm)
{
var flyout = new MenuFlyout { Placement = PlacementMode.BottomEdgeAlignedLeft };
Populate(flyout, vm);
Binders.AddOrUpdate(flyout, new SecondaryActionsBinder(flyout, vm));
return flyout;
}
/// <summary>
/// Stops keeping the flyout's secondary-action subscription so the flyout and its view model
/// can be collected.
/// </summary>
public static void DetachBinder(MenuFlyout flyout)
{
if (Binders.TryGetValue(flyout, out var binder))
{
binder.Detach();
Binders.Remove(flyout);
}
}
private static void Populate(MenuFlyout flyout, GameActionBarViewModel vm)
{
var items = new List<Control>
{
// Primary action (state-driven)
Item("Install", vm, "InstallCommand", visiblePath: "IsInstalled", visibleInvert: true, enabledPath: "CanInstall"),
Item("Play", vm, "PlayCommand", visiblePath: "IsInstalled"),
Item("Update", vm, "UpdateGameCommand", visiblePath: "ShowUpdateLabel"),
Item("Play Without Updating", vm, "PlayOrStopCommand", visiblePath: "IsUpdateAvailable"),
};
// Dynamic, game-defined secondary actions
foreach (var action in vm.SecondaryActions)
items.Add(new MenuItem { Header = action.Name, Command = action.RunCommand });
items.Add(Separator(vm, "IsInstalled"));
items.Add(Item("Browse Files", vm, "BrowseFilesCommand", visiblePath: "IsInstalled"));
items.Add(Item("View Manual", vm, "OpenFirstManualCommand", visiblePath: "HasManuals"));
items.Add(Item("Modify", vm, "ModifyCommand", visiblePath: "IsInstalled"));
items.Add(Separator(vm, "IsInstalled"));
items.Add(Item("Verify Files", vm, "VerifyFilesCommand", visiblePath: "IsInstalled", enabledPath: "IsVerifyingFiles", enabledInvert: true));
items.Add(Item("Uninstall", vm, "UninstallCommand", visiblePath: "IsInstalled", enabledPath: "IsUninstalling", enabledInvert: true));
items.Add(Item("Add to Library", vm, "AddToLibraryCommand", visiblePath: "IsInLibrary", visibleInvert: true));
items.Add(Item("Remove from Library", vm, "RemoveFromLibraryCommand", enabledPath: "IsInLibrary"));
flyout.Items.Clear();
foreach (var item in items)
flyout.Items.Add(item);
}
private static MenuItem Item(
string header,
GameActionBarViewModel vm,
string commandPath,
string? visiblePath = null,
bool visibleInvert = false,
string? enabledPath = null,
bool enabledInvert = false)
{
var item = new MenuItem { Header = header };
item.Bind(MenuItem.CommandProperty, new Binding(commandPath) { Source = vm });
if (visiblePath != null)
item.Bind(Visual.IsVisibleProperty, new Binding(visiblePath)
{
Source = vm,
Converter = visibleInvert ? Invert : null,
});
if (enabledPath != null)
item.Bind(InputElement.IsEnabledProperty, new Binding(enabledPath)
{
Source = vm,
Converter = enabledInvert ? Invert : null,
});
return item;
}
private static Separator Separator(GameActionBarViewModel vm, string visiblePath)
{
var separator = new Separator();
separator.Bind(Visual.IsVisibleProperty, new Binding(visiblePath) { Source = vm });
return separator;
}
/// <summary>
/// Keeps the dynamic secondary actions in the flyout in sync with the view model, which may
/// populate them asynchronously after the flyout is created.
/// </summary>
private sealed class SecondaryActionsBinder
{
private readonly MenuFlyout _flyout;
private readonly GameActionBarViewModel _vm;
public SecondaryActionsBinder(MenuFlyout flyout, GameActionBarViewModel vm)
{
_flyout = flyout;
_vm = vm;
_vm.SecondaryActions.CollectionChanged += OnChanged;
}
private void OnChanged(object? sender, NotifyCollectionChangedEventArgs e)
=> Dispatcher.UIThread.Post(() => Populate(_flyout, _vm));
public void Detach()
=> _vm.SecondaryActions.CollectionChanged -= OnChanged;
}
}

View file

@ -11,7 +11,7 @@ namespace LANCommander.Launcher.Controls;
/// Avalonia render surface. The output preserves the source video's aspect
/// ratio (uniform stretch, centred within the control bounds).
/// </summary>
public class InlineVideoPlayer : Control, IDisposable
public class InlineVideoPlayer : Control, IDisposable, ICarouselPlaybackItem
{
public static readonly StyledProperty<string?> VideoPathProperty =
AvaloniaProperty.Register<InlineVideoPlayer, string?>(nameof(VideoPath));
@ -26,6 +26,12 @@ public class InlineVideoPlayer : Control, IDisposable
private bool _isAttached;
private bool _disposed;
/// <summary>
/// Whether the carousel currently considers this item visible. Playback only
/// runs while both visible and attached, so off-screen videos don't decode.
/// </summary>
private bool _isActive;
/// <summary>Current playback position in milliseconds.</summary>
public long CurrentTimeMs => _renderer?.Player?.Time ?? 0;
@ -49,7 +55,7 @@ public class InlineVideoPlayer : Control, IDisposable
StopPlayback();
var path = change.GetNewValue<string?>();
if (!string.IsNullOrEmpty(path) && _isAttached)
if (!string.IsNullOrEmpty(path) && _isAttached && _isActive)
StartPlayback(path);
}
}
@ -59,10 +65,47 @@ public class InlineVideoPlayer : Control, IDisposable
base.OnAttachedToVisualTree(e);
_isAttached = true;
if (!string.IsNullOrEmpty(VideoPath) && _renderer == null)
// Playback is started by the carousel via SetCarouselActive once it has
// determined which items are on-screen.
if (_isActive && !string.IsNullOrEmpty(VideoPath) && _renderer == null)
StartPlayback(VideoPath);
}
// ── Carousel visibility ──────────────────────────────────────────────
/// <summary>
/// Called by the carousel to indicate whether this item is on-screen.
/// Visible items play (creating the renderer lazily); off-screen items pause
/// so they stop decoding frames and consuming CPU.
/// </summary>
public void SetCarouselActive(bool active)
{
if (_isActive == active)
return;
_isActive = active;
if (!_isAttached || _disposed)
return;
if (active)
{
if (_renderer == null)
{
if (!string.IsNullOrEmpty(VideoPath))
StartPlayback(VideoPath);
}
else
{
_renderer.Player?.SetPause(false);
}
}
else
{
_renderer?.Player?.SetPause(true);
}
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
_isAttached = false;

View file

@ -23,7 +23,7 @@ public class MarkdownTextBlock : StackPanel
AvaloniaProperty.Register<MarkdownTextBlock, string?>(nameof(Text));
public static readonly StyledProperty<double> FontSizeProperty =
AvaloniaProperty.Register<MarkdownTextBlock, double>(nameof(FontSize), 13.0);
AvaloniaProperty.Register<MarkdownTextBlock, double>(nameof(FontSize), 14.0);
private static readonly MarkdownPipeline Pipeline =
new MarkdownPipelineBuilder()
@ -46,6 +46,7 @@ public class MarkdownTextBlock : StackPanel
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == TextProperty || change.Property == FontSizeProperty)
Rebuild();
}
@ -55,6 +56,7 @@ public class MarkdownTextBlock : StackPanel
Children.Clear();
var markdown = Text;
if (string.IsNullOrEmpty(markdown))
return;
@ -90,13 +92,13 @@ public class MarkdownTextBlock : StackPanel
TextWrapping = TextWrapping.Wrap,
FontSize = heading.Level switch
{
1 => FontSize * 1.6,
2 => FontSize * 1.4,
3 => FontSize * 1.2,
_ => FontSize * 1.1,
1 => FontSize * Scale("MarkdownHeading1Scale", 1.6),
2 => FontSize * Scale("MarkdownHeading2Scale", 1.4),
3 => FontSize * Scale("MarkdownHeading3Scale", 1.2),
_ => FontSize * Scale("MarkdownHeadingScale", 1.1),
},
FontWeight = FontWeight.Bold,
Margin = new Thickness(0, 4, 0, 2),
Margin = Space("MarkdownHeadingMargin", new Thickness(0, 4, 0, 2)),
};
if (heading.Inline != null)
@ -112,7 +114,8 @@ public class MarkdownTextBlock : StackPanel
{
TextWrapping = TextWrapping.Wrap,
FontSize = FontSize,
Margin = new Thickness(0, 1, 0, 1),
LineHeight = FontSize * Scale("MarkdownLineHeightScale", 1.4),
Margin = Space("MarkdownParagraphMargin", new Thickness(0, 1, 0, 1)),
};
if (para.Inline != null)
@ -124,20 +127,26 @@ public class MarkdownTextBlock : StackPanel
private Control RenderCodeBlock(string code)
{
return new Border
var text = new TextBlock
{
Background = new SolidColorBrush(Color.Parse("#1AFFFFFF")),
CornerRadius = new CornerRadius(4),
Padding = new Thickness(10, 6),
Margin = new Thickness(0, 3, 0, 3),
Child = new TextBlock
{
Text = code.TrimEnd('\n', '\r'),
FontFamily = new FontFamily("Cascadia Code,Consolas,Menlo,Courier New,monospace"),
FontSize = FontSize * 0.9,
TextWrapping = TextWrapping.Wrap,
},
Text = code.TrimEnd('\n', '\r'),
FontSize = FontSize * Scale("MarkdownCodeFontScale", 0.9),
TextWrapping = TextWrapping.Wrap,
};
text.Bind(TextBlock.FontFamilyProperty, this.GetResourceObservable("CodeFontFamily"));
var border = new Border
{
CornerRadius = Corner("MarkdownCodeCornerRadius", new CornerRadius(4)),
Padding = Space("MarkdownCodeBlockPadding", new Thickness(10, 6)),
Margin = Space("MarkdownCodeBlockMargin", new Thickness(0, 3, 0, 3)),
Child = text,
};
border.Bind(Border.BackgroundProperty, this.GetResourceObservable("CodeSurfaceBrush"));
return border;
}
private Control RenderList(ListBlock list)
@ -171,6 +180,7 @@ public class MarkdownTextBlock : StackPanel
foreach (var block in listItem)
{
var element = RenderBlock(block);
if (element != null)
content.Children.Add(element);
}
@ -184,12 +194,15 @@ public class MarkdownTextBlock : StackPanel
private Control RenderThematicBreak()
{
return new Border
var border = new Border
{
Height = 1,
Background = new SolidColorBrush(Color.Parse("#33FFFFFF")),
Margin = new Thickness(0, 6, 0, 6),
Margin = Space("MarkdownRuleMargin", new Thickness(0, 6, 0, 6)),
};
border.Bind(Border.BackgroundProperty, this.GetResourceObservable("InputBorderBrush"));
return border;
}
private Control RenderQuote(QuoteBlock quote)
@ -202,14 +215,17 @@ public class MarkdownTextBlock : StackPanel
inner.Children.Add(element);
}
return new Border
var border = new Border
{
BorderBrush = new SolidColorBrush(Color.Parse("#4488CC")),
BorderThickness = new Thickness(3, 0, 0, 0),
Padding = new Thickness(8, 4),
Margin = new Thickness(0, 3, 0, 3),
Padding = Space("MarkdownQuotePadding", new Thickness(8, 4)),
Margin = Space("MarkdownQuoteMargin", new Thickness(0, 3, 0, 3)),
Child = inner,
};
border.Bind(Border.BorderBrushProperty, this.GetResourceObservable("PrimaryBrush"));
return border;
}
private IEnumerable<AvaloniaInline> RenderInlines(ContainerInline container)
@ -230,13 +246,18 @@ public class MarkdownTextBlock : StackPanel
break;
case CodeInline code:
yield return new Run(code.Content)
{
var run = new Run(code.Content)
{
FontFamily = new FontFamily("Cascadia Code,Consolas,Menlo,Courier New,monospace"),
Background = new SolidColorBrush(Color.Parse("#22FFFFFF")),
FontSize = FontSize * 0.9,
FontSize = FontSize * Scale("MarkdownCodeFontScale", 0.9),
};
run.Bind(Run.FontFamilyProperty, this.GetResourceObservable("CodeFontFamily"));
run.Bind(Run.BackgroundProperty, this.GetResourceObservable("CodeSurfaceBrush"));
yield return run;
break;
}
case EmphasisInline emphasis:
{
@ -244,11 +265,15 @@ public class MarkdownTextBlock : StackPanel
var isBold = emphasis.DelimiterCount >= 2;
var isItalic = emphasis.DelimiterCount == 1 || emphasis.DelimiterCount == 3;
if (isBold) span.FontWeight = FontWeight.Bold;
if (isItalic) span.FontStyle = FontStyle.Italic;
if (isBold)
span.FontWeight = FontWeight.Bold;
if (isItalic)
span.FontStyle = FontStyle.Italic;
foreach (var child in RenderInlines(emphasis))
span.Inlines.Add(child);
yield return span;
break;
}
@ -258,10 +283,13 @@ public class MarkdownTextBlock : StackPanel
var linkSpan = new Span
{
TextDecorations = TextDecorations.Underline,
Foreground = new SolidColorBrush(Color.Parse("#4488CC")),
};
linkSpan.Bind(Span.ForegroundProperty, this.GetResourceObservable("PrimaryBrush"));
foreach (var child in RenderInlines(link))
linkSpan.Inlines.Add(child);
yield return linkSpan;
break;
}
@ -276,4 +304,13 @@ public class MarkdownTextBlock : StackPanel
break;
}
}
private double Scale(string key, double fallback)
=> this.TryFindResource(key, out var v) && v is double d ? d : fallback;
private Thickness Space(string key, Thickness fallback)
=> this.TryFindResource(key, out var v) && v is Thickness t ? t : fallback;
private CornerRadius Corner(string key, CornerRadius fallback)
=> this.TryFindResource(key, out var v) && v is CornerRadius c ? c : fallback;
}

View file

@ -33,14 +33,17 @@ public class FilePathToBitmapConverter : IValueConverter
if (value is not string path || string.IsNullOrEmpty(path))
return null;
// ConverterParameter="full" → full resolution (e.g. game detail view)
// ConverterParameter="h{N}" → DecodeToHeight(N) (e.g. carousel backgrounds)
// ConverterParameter="full" → full resolution
// ConverterParameter="h{N}" → DecodeToHeight(N)
// ConverterParameter="w{N}" → DecodeToWidth(N) (e.g. game detail background)
// default → DecodeToWidth(320)
bool fullRes = parameter is string p && p == "full";
int? decodeHeight = parameter is string hp && hp.StartsWith("h") && int.TryParse(hp.AsSpan(1), out var h) ? h : null;
int? decodeWidth = parameter is string wp && wp.StartsWith("w") && int.TryParse(wp.AsSpan(1), out var w) ? w : null;
var cacheKey = fullRes ? $"{path}|full"
: decodeHeight.HasValue ? $"{path}|h{decodeHeight}"
: decodeWidth.HasValue ? $"{path}|w{decodeWidth}"
: $"{path}|{DecodeWidth}";
if (_cache.TryGetValue(cacheKey, out var weakRef) && weakRef.TryGetTarget(out var cached))
@ -56,7 +59,7 @@ public class FilePathToBitmapConverter : IValueConverter
? new Bitmap(stream)
: decodeHeight.HasValue
? Bitmap.DecodeToHeight(stream, decodeHeight.Value, BitmapInterpolationMode.HighQuality)
: Bitmap.DecodeToWidth(stream, DecodeWidth, BitmapInterpolationMode.HighQuality);
: Bitmap.DecodeToWidth(stream, decodeWidth ?? DecodeWidth, BitmapInterpolationMode.HighQuality);
_cache[cacheKey] = new WeakReference<Bitmap>(bitmap);
return bitmap;
}

View file

@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
namespace LANCommander.Launcher.Helpers;
/// <summary>
/// Shared, RAM-only image cache for images loaded from either a local file path
/// or an http(s) URL. Decoded bitmaps are held in a byte-bounded LRU so images
/// that scroll back into view (or are revisited while navigating) render instantly
/// without re-fetching or re-decoding.
///
/// Bitmaps returned by <see cref="LoadAsync"/> / <see cref="TryGet"/> are owned by
/// the cache and must NOT be disposed by callers — eviction disposes them once they
/// fall out of the budget.
/// </summary>
public static class RemoteImageCache
{
private static readonly HttpClient _httpClient = new();
private static readonly Dictionary<string, Bitmap> _cache = new();
private static readonly LinkedList<string> _order = new();
private static readonly Dictionary<string, LinkedListNode<string>> _nodes = new();
private static readonly object _lock = new();
private static long _cacheBytes;
private const long MaxCacheBytes = 128L * 1024 * 1024;
/// <summary>
/// Synchronous fast path: returns a cached bitmap for the given source/decode size
/// if one is present, refreshing its position in the LRU. Never touches disk or network.
/// </summary>
public static bool TryGet(string source, int decodeWidth, int decodeHeight, out Bitmap? bitmap)
{
var key = BuildKey(source, decodeWidth, decodeHeight);
lock (_lock)
{
if (_cache.TryGetValue(key, out bitmap))
{
Touch(key);
return true;
}
}
bitmap = null;
return false;
}
/// <summary>
/// Returns a decoded bitmap for <paramref name="source"/> (a local file path or an
/// http(s) URL), fetching and decoding off the calling thread when not already cached.
/// Pass <paramref name="decodeWidth"/> to downscale by width, else <paramref name="decodeHeight"/>
/// to downscale by height, else both zero to decode at full resolution.
/// </summary>
public static async Task<Bitmap?> LoadAsync(string source, int decodeWidth, int decodeHeight, CancellationToken ct)
{
if (string.IsNullOrEmpty(source))
return null;
if (TryGet(source, decodeWidth, decodeHeight, out var cached))
return cached;
byte[]? data = null;
if (IsHttp(source))
{
data = await _httpClient.GetByteArrayAsync(source, ct);
if (ct.IsCancellationRequested)
return null;
}
var bitmap = await Task.Run(() => Decode(source, data, decodeWidth, decodeHeight), ct);
if (bitmap == null)
return null;
if (ct.IsCancellationRequested)
{
bitmap.Dispose();
return null;
}
return Insert(BuildKey(source, decodeWidth, decodeHeight), bitmap);
}
private static Bitmap? Decode(string source, byte[]? data, int decodeWidth, int decodeHeight)
{
Stream stream;
if (data != null)
{
stream = new MemoryStream(data);
}
else
{
if (!File.Exists(source))
return null;
stream = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read);
}
using (stream)
{
if (decodeWidth > 0)
return Bitmap.DecodeToWidth(stream, decodeWidth, BitmapInterpolationMode.HighQuality);
if (decodeHeight > 0)
return Bitmap.DecodeToHeight(stream, decodeHeight, BitmapInterpolationMode.HighQuality);
return new Bitmap(stream);
}
}
/// <summary>
/// Inserts a freshly-decoded bitmap, or returns the already-cached instance if another
/// caller decoded the same key concurrently (disposing the duplicate). Evicts
/// least-recently-used entries until back under the memory budget.
/// </summary>
private static Bitmap Insert(string key, Bitmap bitmap)
{
lock (_lock)
{
if (_cache.TryGetValue(key, out var existing))
{
bitmap.Dispose();
Touch(key);
return existing;
}
_cache[key] = bitmap;
_nodes[key] = _order.AddLast(key);
_cacheBytes += BitmapBytes(bitmap);
// Evict LRU entries until under budget, always keeping the one just added.
while (_cacheBytes > MaxCacheBytes && _order.Count > 1)
{
var oldest = _order.First!.Value;
_order.RemoveFirst();
_nodes.Remove(oldest);
if (_cache.Remove(oldest, out var evicted))
{
_cacheBytes -= BitmapBytes(evicted);
evicted.Dispose();
}
}
return bitmap;
}
}
private static void Touch(string key)
{
if (_nodes.TryGetValue(key, out var node))
{
_order.Remove(node);
_order.AddLast(node);
}
}
private static string BuildKey(string source, int decodeWidth, int decodeHeight) =>
decodeWidth > 0 ? $"{source}|w{decodeWidth}"
: decodeHeight > 0 ? $"{source}|h{decodeHeight}"
: $"{source}|full";
private static bool IsHttp(string source) =>
source.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| source.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
private static long BitmapBytes(Bitmap bitmap) =>
(long)bitmap.PixelSize.Width * bitmap.PixelSize.Height * 4;
}

View file

@ -0,0 +1,142 @@
using System;
using System.Globalization;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using LANCommander.Launcher.Converters;
using LANCommander.Launcher.ViewModels;
using LANCommander.Launcher.Views;
namespace LANCommander.Launcher.Helpers;
public static class TrayIconExtensions
{
/// <summary>
/// Creates the system tray icon for the launcher. The main window hides to tray on
/// close (see <see cref="MainWindow"/>'s Closing handler), so the tray provides a way
/// to navigate back into the app and to exit. The menu is rebuilt whenever the window
/// hides to tray so the recently played list stays current. The returned icon is
/// disposed automatically when the window closes.
/// </summary>
public static TrayIcon CreateTrayIcon(this MainWindow mainWindow, MainWindowViewModel mainViewModel)
{
var trayIcon = new TrayIcon
{
Icon = new WindowIcon(AssetLoader.Open(
new Uri("avares://LANCommander.Launcher/Assets/icon.ico"))),
ToolTipText = "LANCommander",
IsVisible = true,
};
void RestoreWindow()
{
mainWindow.Show();
if (mainWindow.WindowState == WindowState.Minimized)
mainWindow.WindowState = WindowState.Normal;
mainWindow.Activate();
}
var shell = mainViewModel.ShellViewModel;
NativeMenu BuildTrayMenu()
{
var menu = new NativeMenu();
var depotItem = new NativeMenuItem("Depot");
depotItem.Click += (_, _) =>
{
RestoreWindow();
if (mainViewModel.IsShellActive)
shell.ShowDepotCommand.Execute(null);
};
var libraryItem = new NativeMenuItem("Library");
libraryItem.Click += (_, _) =>
{
RestoreWindow();
if (mainViewModel.IsShellActive)
shell.ShowLibraryCommand.Execute(null);
};
var settingsItem = new NativeMenuItem("Settings");
settingsItem.Click += (_, _) =>
{
RestoreWindow();
if (mainViewModel.IsShellActive)
shell.ShowSettingsCommand.Execute(null);
};
menu.Add(depotItem);
menu.Add(libraryItem);
menu.Add(settingsItem);
// Last 5 recently played games (available once the shell/library is loaded)
var recentGames = shell.LibraryViewModel?.RecentlyPlayedGames;
if (recentGames is { Count: > 0 })
{
menu.Add(new NativeMenuItemSeparator());
foreach (var game in recentGames.Take(5))
{
var gameId = game.Id;
var isInstalled = game.IsInstalled;
var recentItem = new NativeMenuItem(game.Title)
{
Icon = FilePathToBitmapConverter.Instance.Convert(
game.IconPath, typeof(Bitmap), "h32", CultureInfo.CurrentCulture) as Bitmap,
};
recentItem.Click += async (_, _) =>
{
if (!mainViewModel.IsShellActive)
return;
// Installed games launch directly; uninstalled ones open their
// detail page so the user can install them.
if (isInstalled)
await shell.RunGameByIdAsync(gameId);
else
{
RestoreWindow();
await shell.NavigateToGameByIdAsync(gameId);
}
};
menu.Add(recentItem);
}
}
menu.Add(new NativeMenuItemSeparator());
var exitItem = new NativeMenuItem("Exit");
exitItem.Click += (_, _) => mainWindow.ExitApplication();
menu.Add(exitItem);
return menu;
}
trayIcon.Menu = BuildTrayMenu();
// Rebuild the menu each time the window hides to tray so the recently
// played list reflects the latest play sessions.
mainWindow.HiddenToTray += (_, _) => trayIcon.Menu = BuildTrayMenu();
trayIcon.Clicked += (_, _) => RestoreWindow();
mainWindow.Closed += (_, _) => trayIcon.Dispose();
return trayIcon;
}
}

View file

@ -169,6 +169,7 @@ public sealed class GamepadService : IDisposable
// Button → key mapping
// South (A) / Start → Enter (confirm / activate)
// East (B) → Escape (back)
// North (Y) → Apps (open game context menu)
// D-pad → arrow keys
// LB / RB → Page Up / Page Down
// -------------------------------------------------------------------------
@ -180,6 +181,7 @@ public sealed class GamepadService : IDisposable
SDL_GamepadButton.SDL_GAMEPAD_BUTTON_SOUTH => Key.Return,
SDL_GamepadButton.SDL_GAMEPAD_BUTTON_START => Key.Return,
SDL_GamepadButton.SDL_GAMEPAD_BUTTON_EAST => Key.Escape,
SDL_GamepadButton.SDL_GAMEPAD_BUTTON_NORTH => Key.Apps,
SDL_GamepadButton.SDL_GAMEPAD_BUTTON_DPAD_UP => Key.Up,
SDL_GamepadButton.SDL_GAMEPAD_BUTTON_DPAD_DOWN => Key.Down,
SDL_GamepadButton.SDL_GAMEPAD_BUTTON_DPAD_LEFT => Key.Left,

View file

@ -36,6 +36,10 @@
<PackageReference Include="ppy.SDL3-CS" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="LANCommander.Launcher.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LANCommander.Launcher.Services\LANCommander.Launcher.Services.csproj" />
<ProjectReference Include="..\LANCommander.Launcher.Settings\LANCommander.Launcher.Settings.csproj" />

View file

@ -58,6 +58,15 @@ class Program
}
}
// Only one GUI instance should run at a time. The launcher hides to the tray on
// close, so a user may relaunch it without realizing it's still running. If another
// instance already holds the lock, ask it to surface its window, then exit.
if (!SingleInstanceService.TryAcquireInstanceLock())
{
SingleInstanceService.TrySendToServer("restore");
return;
}
BigScreenMode = args.Any(a => a.Equals("--big-screen", StringComparison.OrdinalIgnoreCase));
BuildAvaloniaApp()

View file

@ -49,17 +49,17 @@ public partial class NavigationService : ObservableObject, INavigationService
}
// Show the loading overlay and yield so the UI thread can paint it
// BEFORE the heavy view creation from the ContentControl DataTemplate.
// over the CURRENT view before we do any heavy work.
IsNavigating = true;
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Render).GetTask();
CurrentView = viewModel;
try
{
if (initializeAsync != null)
await initializeAsync();
CurrentView = viewModel;
// Yield again so the new view finishes layout/render
// before we hide the overlay.
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background).GetTask();
@ -67,6 +67,8 @@ public partial class NavigationService : ObservableObject, INavigationService
catch
{
// Initialization errors are handled by the caller's callback.
// Still swap to the target view so navigation isn't left stuck.
CurrentView = viewModel;
}
finally
{

View file

@ -21,17 +21,35 @@ public class SingleInstanceService : IDisposable
{
private const string PipeName = "lancommander-launcher";
private const string ProtocolScheme = "lancommander";
private const string MutexName = "lancommander-launcher-singleton";
// Held for the lifetime of the process to mark this as the owning instance.
private static Mutex? _instanceMutex;
private readonly ILogger<SingleInstanceService> _logger;
private CancellationTokenSource? _cts;
public event EventHandler<Guid>? NavigateToGameRequested;
/// <summary>Raised when a secondary instance asks the running one to surface.</summary>
public event EventHandler? RestoreRequested;
public SingleInstanceService(ILogger<SingleInstanceService> logger)
{
_logger = logger;
}
/// <summary>
/// Claims the single-instance lock for this process. Returns true when this is the first
/// instance, false when another instance already holds the lock. The mutex is intentionally
/// kept alive for the lifetime of the process and released by the OS on exit.
/// </summary>
public static bool TryAcquireInstanceLock()
{
_instanceMutex = new Mutex(initiallyOwned: true, MutexName, out var createdNew);
return createdNew;
}
// ── Server (first instance) ──────────────────────────────────────────────
/// <summary>Start listening for messages from secondary instances.</summary>
@ -71,6 +89,13 @@ public class SingleInstanceService : IDisposable
private void HandleMessage(string message)
{
if (message.Equals("restore", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Restore request received from a secondary instance");
RestoreRequested?.Invoke(this, EventArgs.Empty);
return;
}
if (message.StartsWith("navigate-game:", StringComparison.OrdinalIgnoreCase))
{
var idStr = message["navigate-game:".Length..].Trim();

View file

@ -1,112 +1,109 @@
using System;
using System.Runtime.InteropServices;
using LANCommander.SDK.Enums;
using LANCommander.SDK.Services;
using Microsoft.Extensions.Logging;
using Notify.NET.Abstractions;
namespace LANCommander.Launcher.Services;
/// <summary>
/// Drives the OS taskbar/Dock progress indicator for the active download,
/// backed by Notify.NET's cross-platform <see cref="ITaskbarProgressService"/>.
/// </summary>
public class TaskbarProgressService
{
private readonly ITaskbarProgressService _taskbar;
private readonly ILogger<TaskbarProgressService> _logger;
private ITaskbarList3? _taskbarList;
private IntPtr _hwnd;
public TaskbarProgressService(ILogger<TaskbarProgressService> logger)
public TaskbarProgressService(ITaskbarProgressService taskbar, ILogger<TaskbarProgressService> logger)
{
_taskbar = taskbar;
_logger = logger;
}
public void Initialize(IntPtr hwnd)
{
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) return;
_logger.LogInformation("TaskbarProgress.Initialize: IsSupported={IsSupported}, hwnd={Hwnd}", _taskbar.IsSupported, hwnd);
_hwnd = hwnd;
if (!_taskbar.IsSupported)
return;
try
{
_taskbarList = (ITaskbarList3)new TaskbarListInstance();
_taskbarList.HrInit();
_taskbar.SetWindow(hwnd);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to initialize ITaskbarList3");
_taskbarList = null;
_logger.LogWarning(ex, "Failed to bind taskbar progress to window handle");
}
}
public void SetProgress(float progress)
/// <summary>
/// Reflects the current install item's progress and status on the taskbar:
/// indeterminate phases pulse, active transfers show a value, failures turn red.
/// </summary>
public void Report(InstallProgress progress)
{
if (_taskbarList == null || _hwnd == IntPtr.Zero) return;
if (!_taskbar.IsSupported)
return;
try
{
const ulong total = 100_000;
var completed = (ulong)(progress * total);
_taskbarList.SetProgressState(_hwnd, TBPFLAG.TBPF_NORMAL);
_taskbarList.SetProgressValue(_hwnd, completed, total);
_logger.LogDebug("TaskbarProgress.Report: Status={Status}, Indeterminate={Indeterminate}, Progress={Progress}", progress.Status, progress.Indeterminate, progress.Progress);
switch (progress.Status)
{
case InstallStatus.Failed:
_taskbar.SetState(TaskbarProgressState.Error);
break;
case InstallStatus.Canceled:
case InstallStatus.Complete:
_taskbar.SetState(TaskbarProgressState.None);
break;
default:
// Progress is BytesTransferred/TotalBytes and is NaN before the total is
// known (division by zero). Treat that—and any non-finite value—as an
// indeterminate pulse rather than feeding NaN to the native indicator.
if (progress.Indeterminate || float.IsNaN(progress.Progress) || float.IsInfinity(progress.Progress))
_taskbar.SetState(TaskbarProgressState.Indeterminate);
else
_taskbar.SetProgress(progress.Progress);
break;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to set taskbar progress");
_logger.LogWarning(ex, "Failed to update taskbar progress");
}
}
public void SetIndeterminate()
public void SetError()
{
if (_taskbarList == null || _hwnd == IntPtr.Zero) return;
if (!_taskbar.IsSupported)
return;
try
{
_taskbarList.SetProgressState(_hwnd, TBPFLAG.TBPF_INDETERMINATE);
_taskbar.SetState(TaskbarProgressState.Error);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to set indeterminate taskbar progress");
_logger.LogWarning(ex, "Failed to set taskbar error state");
}
}
public void ClearProgress()
{
if (_taskbarList == null || _hwnd == IntPtr.Zero) return;
if (!_taskbar.IsSupported)
return;
try
{
_taskbarList.SetProgressState(_hwnd, TBPFLAG.TBPF_NOPROGRESS);
_taskbar.SetState(TaskbarProgressState.None);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to clear taskbar progress");
}
}
// ── COM interop ──────────────────────────────────────────────────────────
[Flags]
private enum TBPFLAG
{
TBPF_NOPROGRESS = 0x00,
TBPF_INDETERMINATE = 0x01,
TBPF_NORMAL = 0x02,
TBPF_ERROR = 0x04,
TBPF_PAUSED = 0x08,
}
[ComImport]
[Guid("ea1afb91-9e28-4b86-90e9-9e9f8a5eefaf")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface ITaskbarList3
{
void HrInit();
void AddTab(IntPtr hwnd);
void DeleteTab(IntPtr hwnd);
void ActivateTab(IntPtr hwnd);
void SetActiveAlt(IntPtr hwnd);
void MarkFullscreenWindow(IntPtr hwnd, [MarshalAs(UnmanagedType.Bool)] bool fullscreen);
void SetProgressValue(IntPtr hwnd, ulong completed, ulong total);
void SetProgressState(IntPtr hwnd, TBPFLAG state);
}
[ComImport]
[Guid("56fdf344-fd6d-11d0-958a-006097c9a090")]
[ClassInterface(ClassInterfaceType.None)]
private class TaskbarListInstance { }
}

View file

@ -142,6 +142,11 @@
<Color x:Key="ButtonGlassDarkHoverBg">#EE111111</Color>
<Color x:Key="ButtonGlassDarkPressedBg">#FF111111</Color>
<!-- Button - Default (neutral elevated surface) -->
<Color x:Key="ButtonDefaultBg">#303030</Color>
<Color x:Key="ButtonDefaultHover">#3A3A3A</Color>
<Color x:Key="ButtonDefaultActive">#424242</Color>
<!-- Button - Shared -->
<Color x:Key="ButtonDisabledBg">#14FFFFFF</Color>
<Color x:Key="ButtonDisabledText">#40FFFFFF</Color>
@ -193,6 +198,10 @@
<SolidColorBrush x:Key="ButtonGlassDarkHoverBgBrush" Color="{StaticResource ButtonGlassDarkHoverBg}" />
<SolidColorBrush x:Key="ButtonGlassDarkPressedBgBrush" Color="{StaticResource ButtonGlassDarkPressedBg}" />
<SolidColorBrush x:Key="ButtonDefaultBgBrush" Color="{StaticResource ButtonDefaultBg}" />
<SolidColorBrush x:Key="ButtonDefaultHoverBrush" Color="{StaticResource ButtonDefaultHover}" />
<SolidColorBrush x:Key="ButtonDefaultActiveBrush" Color="{StaticResource ButtonDefaultActive}" />
<SolidColorBrush x:Key="ButtonDisabledBgBrush" Color="{StaticResource ButtonDisabledBg}" />
<SolidColorBrush x:Key="ButtonDisabledTextBrush" Color="{StaticResource ButtonDisabledText}" />
@ -212,4 +221,33 @@
<SolidColorBrush x:Key="ToastBgBrush" Color="{StaticResource ToastBg}" />
<SolidColorBrush x:Key="ToastBorderBrush" Color="{StaticResource ToastBorder}" />
<SolidColorBrush x:Key="ToastTextBrush" Color="{StaticResource ToastText}" />
<!-- ===================== -->
<!-- Markdown / Typography -->
<!-- ===================== -->
<!-- Code surface (shared by fenced code blocks and inline code) -->
<Color x:Key="CodeSurface">#1AFFFFFF</Color>
<SolidColorBrush x:Key="CodeSurfaceBrush" Color="{StaticResource CodeSurface}" />
<!-- Monospace font for code -->
<FontFamily x:Key="CodeFontFamily">Cascadia Code,Consolas,Menlo,Courier New,monospace</FontFamily>
<!-- Markdown heading scale factors (multiplied against base FontSize) -->
<x:Double x:Key="MarkdownHeading1Scale">1.6</x:Double>
<x:Double x:Key="MarkdownHeading2Scale">1.4</x:Double>
<x:Double x:Key="MarkdownHeading3Scale">1.2</x:Double>
<x:Double x:Key="MarkdownHeadingScale">1.1</x:Double> <!-- h4+ -->
<x:Double x:Key="MarkdownCodeFontScale">0.9</x:Double>
<x:Double x:Key="MarkdownLineHeightScale">1.6</x:Double>
<!-- Markdown whitespace (kept in the stylesheet so spacing is tunable globally) -->
<Thickness x:Key="MarkdownHeadingMargin">0,4,0,2</Thickness>
<Thickness x:Key="MarkdownParagraphMargin">0,4,0,4</Thickness>
<Thickness x:Key="MarkdownCodeBlockMargin">0,3,0,3</Thickness>
<Thickness x:Key="MarkdownCodeBlockPadding">10,6</Thickness>
<Thickness x:Key="MarkdownRuleMargin">0,6,0,6</Thickness>
<Thickness x:Key="MarkdownQuoteMargin">0,3,0,3</Thickness>
<Thickness x:Key="MarkdownQuotePadding">8,4</Thickness>
<CornerRadius x:Key="MarkdownCodeCornerRadius">4</CornerRadius>
</ResourceDictionary>

View file

@ -8,21 +8,20 @@
<!-- ===================================================== -->
<Style Selector="Button, local|IconButton">
<Setter Property="Background" Value="{DynamicResource BackgroundBrush}" />
<Setter Property="Background" Value="{DynamicResource ButtonDefaultBgBrush}" />
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource InputBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="CornerRadius" Value="2" />
<Setter Property="Padding" Value="12,6" />
</Style>
<Style Selector="Button.Default:pointerover /template/ ContentPresenter#PART_ContentPresenter,
local|IconButton.Default:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="BorderBrush" Value="{DynamicResource Primary5Brush}" />
<Style Selector="Button:pointerover /template/ ContentPresenter#PART_ContentPresenter,
local|IconButton:pointerover /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource ButtonDefaultHoverBrush}" />
</Style>
<Style Selector="Button.Default:pressed /template/ ContentPresenter#PART_ContentPresenter,
local|IconButton.Default:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="BorderBrush" Value="{DynamicResource Primary7Brush}" />
<Style Selector="Button:pressed /template/ ContentPresenter#PART_ContentPresenter,
local|IconButton:pressed /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="{DynamicResource ButtonDefaultActiveBrush}" />
</Style>
<!-- Focus-visible: highlight with inset box-shadow so layout is not affected -->
@ -527,6 +526,17 @@
<Setter Property="Background" Value="{DynamicResource ButtonGlassDarkPressedBgBrush}" />
</Style>
<!-- ===================================================== -->
<!-- BUTTONS — Dialog/overlay footer actions -->
<!-- (OK, Cancel, Confirm, Save, etc.) -->
<!-- ===================================================== -->
<!-- Mark a footer container with Classes="DialogFooter"; its buttons inherit these -->
<Style Selector=".DialogFooter Button">
<Setter Property="MinWidth" Value="90" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
</Style>
<!-- ===================================================== -->
<!-- TEXT INPUTS -->
<!-- ===================================================== -->
@ -558,6 +568,28 @@
<Setter Property="Foreground" Value="{DynamicResource TextDisabledBrush}" />
</Style>
<!-- ===================================================== -->
<!-- COMBOBOX (SELECT) -->
<!-- ===================================================== -->
<Style Selector="ComboBox">
<Setter Property="BorderThickness" Value="0" />
</Style>
<Style Selector="ComboBox /template/ Border#Background">
<Setter Property="BorderThickness" Value="0" />
</Style>
<!-- ===================================================== -->
<!-- NUMERIC UP/DOWN -->
<!-- ===================================================== -->
<Style Selector="NumericUpDown">
<Setter Property="BorderThickness" Value="0" />
</Style>
<Style Selector="NumericUpDown /template/ ButtonSpinner">
<Setter Property="BorderThickness" Value="0" />
</Style>
<!-- ===================================================== -->
<!-- TOAST (CUSTOM CONTROL STYLE) -->
<!-- ===================================================== -->

View file

@ -31,7 +31,7 @@ namespace LANCommander.Launcher.ViewModels.Components;
/// ViewModel for the game action bar component.
/// Handles play, install, uninstall, and library management actions.
/// </summary>
public partial class GameActionBarViewModel : ViewModelBase
public partial class GameActionBarViewModel : ViewModelBase, IDisposable
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<GameActionBarViewModel> _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
/// <summary>
/// Shows "Play" label when no update available and game is idle (not running/starting)
/// </summary>
public bool ShowPlayLabel => !IsUpdateAvailable && !IsRunning && !IsStarting;
public bool ShowPlayLabel => !IsRunning && (!IsUpdateAvailable || IsStarting);
/// <summary>
/// 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<LibraryService>();
var settingsProvider = scope.ServiceProvider.GetRequiredService<ISettingsProvider>();
@ -212,6 +219,44 @@ public partial class GameActionBarViewModel : ViewModelBase
_ = CheckForUpdateFromServerAsync(game.Id, game.InstalledVersion);
}
/// <summary>
/// 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.
/// </summary>
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<LibraryService>();
var gameService = scope.ServiceProvider.GetRequiredService<GameService>();
var settingsProvider = scope.ServiceProvider.GetRequiredService<ISettingsProvider>();
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();
}
}
/// <summary>
/// Loads the action bar state for a game from SDK model
/// </summary>
@ -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;
}
/// <summary>
/// Stops any polling timers. Used by transient menu-backing instances so they don't
/// leave background timers running after the menu closes.
/// </summary>
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");
}
}
/// <summary>
/// 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.
/// </summary>
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");
}
/// <summary>
/// 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<Data.DatabaseContext>();
var localGame = await dbContext.Set<Data.Models.Game>()
.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<Guid>(
(localGame.DependentGames ?? [])
.Where(a => a.Installed)
.Select(a => a.Id));
await dbContext.Set<Data.Models.Game>()
.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<Guid>(
(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();

View file

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

View file

@ -8,11 +8,21 @@ public partial class GameMediaItemViewModel : ObservableObject
{
/// <summary>Local file path or remote URL for videos.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(VideoPath))]
private string _path = string.Empty;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(VideoPath))]
private bool _isVideo;
/// <summary>
/// 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.
/// </summary>
public string? VideoPath => IsVideo ? Path : null;
[ObservableProperty]
private string _mimeType = string.Empty;

View file

@ -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<LibraryItemViewModel>? ItemSelected;
public event EventHandler? RefreshRequested;
@ -79,6 +82,19 @@ public partial class LibrarySidebarViewModel : ViewModelBase
var mediaService = scope.ServiceProvider.GetRequiredService<MediaService>();
var mediaClient = scope.ServiceProvider.GetRequiredService<MediaClient>();
if (!IsOfflineMode)
{
try
{
var authenticationClient = scope.ServiceProvider.GetRequiredService<AuthenticationClient>();
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 ?? [])

View file

@ -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<DepotViewModel> _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<Guid, string?>();
var coverMimeCache = new Dictionary<Guid, string?>();
var librarySet = new HashSet<Guid>();
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<string?> 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);
}
}

View file

@ -85,6 +85,8 @@ public partial class DownloadQueueViewModel : ViewModelBase
public event EventHandler<Guid>? InstallCompleted;
public event EventHandler<Guid>? 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<MediaService>();
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<DisplayAttribute>();
return display?.Name ?? status.ToString();
}
}

View file

@ -200,150 +200,6 @@ public partial class GameDetailViewModel : ViewModelBase
await ActionBar.RefreshAsync();
}
/// <summary>
/// Load game from local cache (Data.Models.Game)
/// Used when selecting from the library sidebar
/// </summary>
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<MediaService>();
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;
}
/// <summary>
/// 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<MediaClient>();
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);
}
}
/// <summary>
/// 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.
/// </summary>
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<MediaClient>();
var screenshotTasks = new List<Task>();
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
}
}
/// <summary>
/// Downloads (if needed) and decodes a single screenshot, then swaps it into the
/// carousel in place of its skeleton. Runs concurrently with other screenshots.
/// </summary>
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);
}
}
/// <summary>
/// Replaces the skeleton at <paramref name="index"/> 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.
/// </summary>
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<Data.Models.Media>? 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<string?> GetOrDownloadMediaPathAsync(System.Collections.Generic.IEnumerable<SDK.Models.Media> 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<SDK.Models.Media> 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;
}
}

View file

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

View file

@ -67,9 +67,16 @@ public partial class LibraryViewModel : GamesCollectionViewModel
var mediaClient = scope.ServiceProvider.GetRequiredService<MediaClient>();
var dbContext = scope.ServiceProvider.GetRequiredService<DbContext>();
if (!IsOfflineMode)
{
var moduleClient = scope.ServiceProvider.GetRequiredService<ModuleClient>();
await moduleClient.SyncAsync();
}
var items = await libraryService.GetItemsAsync();
var results = new List<GameItemViewModel>();
var gameModels = new List<LANCommander.Launcher.Data.Models.Game>();
var iconPaths = new Dictionary<Guid, string?>();
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
}
}
/// <summary>
/// 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.
/// </summary>
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<LibraryClient>();
var mediaClient = scope.ServiceProvider.GetRequiredService<MediaClient>();
var games = await libraryClient.GetGamesAsync();
var results = new List<GameItemViewModel>();
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);

View file

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

View file

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

View file

@ -0,0 +1,38 @@
using System;
using System.IO;
using LANCommander.SDK.Services;
namespace LANCommander.Launcher.ViewModels;
/// <summary>
/// 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.
/// </summary>
public static class MediaSourceResolver
{
/// <summary>
/// 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.
/// </summary>
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);
}
}

View file

@ -24,6 +24,9 @@ public partial class SettingsViewModel : ViewModelBase
[ObservableProperty]
private ObservableCollection<InstallDirectoryItem> _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;

View file

@ -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<AuthenticationClient>();
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<AuthenticationService>();
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<LANCommander.Launcher.Services.GameService>();
var gameService = scope.ServiceProvider.GetRequiredService<GameService>();
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
}
}
/// <summary>
/// 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.
/// </summary>
public async Task RunGameByIdAsync(Guid gameId)
{
try
{
using var scope = _serviceProvider.CreateScope();
var gameService = scope.ServiceProvider.GetRequiredService<GameService>();
var localGame = await gameService.GetAsync(gameId);
if (localGame is not { Installed: true })
return;
var gameClient = scope.ServiceProvider.GetRequiredService<GameClient>();
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);
}

View file

@ -37,7 +37,7 @@
</Border>
<!-- Footer -->
<Border Padding="20,16,20,16">
<Border Padding="20,16,20,16" Classes="DialogFooter">
<Button Content="OK"
Classes="Primary"
HorizontalAlignment="Right"

View file

@ -19,11 +19,14 @@ public partial class Cover : UserControl
// Strong-reference bitmap cache keyed by "path|decodeWidth" so covers that
// scroll back into view are displayed instantly without re-reading the file.
// Bounded by total decoded bytes (not entry count) because cover bitmap size
// scales with DPI — a 500-entry cap is ~300 MB at 100% but ~800 MB at 200%.
private static readonly Dictionary<string, Bitmap> _bitmapCache = new();
private static readonly LinkedList<string> _cacheOrder = new();
private static readonly Dictionary<string, LinkedListNode<string>> _cacheNodes = new();
private static readonly object _cacheLock = new();
private const int MaxCacheEntries = 500;
private static long _cacheBytes;
private const long MaxCacheBytes = 128L * 1024 * 1024;
public static readonly StyledProperty<string?> SourceProperty =
AvaloniaProperty.Register<Cover, string?>(nameof(Source));
@ -56,6 +59,9 @@ public partial class Cover : UserControl
private bool _isAnimatedCover;
private bool _receivedFirstFrame;
private string? _lastLoadedSource;
// Source for an animated cover whose stream is deferred until it should actually
// play (hover/focus), so the depot doesn't stream every video cover up front.
private string? _animatedSource;
public string? Source
{
@ -207,6 +213,7 @@ public partial class Cover : UserControl
_isAnimatedCover = IsAnimatedMimeType(mimeType);
_lastLoadedSource = source;
_animatedSource = _isAnimatedCover ? source : null;
if (string.IsNullOrEmpty(source))
{
@ -214,10 +221,16 @@ public partial class Cover : UserControl
return;
}
// Route all animated covers (video, APNG, GIF) through LibVLC
// Route all animated covers (video, APNG, GIF) through LibVLC — but only start
// streaming when we should actually be animating. Otherwise defer until hover
// (UpdateAnimationState) and show the title placeholder in the meantime.
if (_isAnimatedCover)
{
LoadAnimatedCover(source);
if (AlwaysAnimate || IsPlayingAnimation)
LoadAnimatedCover(source);
else
SetBitmap(null);
return;
}
@ -279,40 +292,7 @@ public partial class Cover : UserControl
}
if (bitmap != null)
{
lock (_cacheLock)
{
// Another Cover may have cached the same key while we were
// decoding; prefer the existing entry to avoid duplicates.
if (_bitmapCache.TryGetValue(cacheKey, out var existing))
{
bitmap.Dispose();
bitmap = existing;
if (_cacheNodes.TryGetValue(cacheKey, out var existingNode))
{
_cacheOrder.Remove(existingNode);
_cacheOrder.AddLast(existingNode);
}
}
else
{
_bitmapCache[cacheKey] = bitmap;
var newNode = _cacheOrder.AddLast(cacheKey);
_cacheNodes[cacheKey] = newNode;
}
// Evict oldest entries when the cache is full.
while (_cacheOrder.Count > MaxCacheEntries)
{
var oldestNode = _cacheOrder.First!;
var oldest = oldestNode.Value;
_cacheOrder.RemoveFirst();
_cacheNodes.Remove(oldest);
if (_bitmapCache.Remove(oldest, out var evicted))
evicted.Dispose();
}
}
}
bitmap = CacheBitmap(cacheKey, bitmap);
// Use Background priority so multiple covers completing together
// coalesce into fewer layout passes instead of each forcing one.
@ -362,35 +342,7 @@ public partial class Cover : UserControl
return;
}
lock (_cacheLock)
{
if (_bitmapCache.TryGetValue(cacheKey, out var existing))
{
bitmap.Dispose();
bitmap = existing;
if (_cacheNodes.TryGetValue(cacheKey, out var existingNode))
{
_cacheOrder.Remove(existingNode);
_cacheOrder.AddLast(existingNode);
}
}
else
{
_bitmapCache[cacheKey] = bitmap;
var newNode = _cacheOrder.AddLast(cacheKey);
_cacheNodes[cacheKey] = newNode;
}
while (_cacheOrder.Count > MaxCacheEntries)
{
var oldestNode = _cacheOrder.First!;
var oldest = oldestNode.Value;
_cacheOrder.RemoveFirst();
_cacheNodes.Remove(oldest);
if (_bitmapCache.Remove(oldest, out var evicted))
evicted.Dispose();
}
}
bitmap = CacheBitmap(cacheKey, bitmap);
await Dispatcher.UIThread.InvokeAsync(() => SetBitmap(bitmap), DispatcherPriority.Background);
}
@ -401,6 +353,55 @@ public partial class Cover : UserControl
}
}
/// <summary>
/// Inserts a freshly-decoded bitmap into the shared LRU cache, or returns the
/// already-cached instance if another <see cref="Cover"/> decoded the same key
/// concurrently (disposing the duplicate). Evicts least-recently-used entries
/// until the cache is back under its memory budget, disposing each evicted
/// bitmap so its native (Skia) memory is freed immediately.
/// </summary>
private static Bitmap CacheBitmap(string cacheKey, Bitmap bitmap)
{
lock (_cacheLock)
{
if (_bitmapCache.TryGetValue(cacheKey, out var existing))
{
bitmap.Dispose();
bitmap = existing;
if (_cacheNodes.TryGetValue(cacheKey, out var existingNode))
{
_cacheOrder.Remove(existingNode);
_cacheOrder.AddLast(existingNode);
}
}
else
{
_bitmapCache[cacheKey] = bitmap;
_cacheNodes[cacheKey] = _cacheOrder.AddLast(cacheKey);
_cacheBytes += BitmapBytes(bitmap);
}
// Evict least-recently-used entries until under the byte budget, but
// always keep the entry we just touched (the tail of the LRU list).
while (_cacheBytes > MaxCacheBytes && _cacheOrder.Count > 1)
{
var oldest = _cacheOrder.First!.Value;
_cacheOrder.RemoveFirst();
_cacheNodes.Remove(oldest);
if (_bitmapCache.Remove(oldest, out var evicted))
{
_cacheBytes -= BitmapBytes(evicted);
evicted.Dispose();
}
}
return bitmap;
}
}
private static long BitmapBytes(Bitmap bitmap) =>
(long)bitmap.PixelSize.Width * bitmap.PixelSize.Height * 4;
// ── Animated cover support (video, APNG, GIF via LibVLC) ────────────
private void LoadAnimatedCover(string source)
@ -447,19 +448,40 @@ public partial class Cover : UserControl
private void UpdateAnimationState()
{
if (!_isAnimatedCover || _videoRenderer?.Player == null) return;
if (!_isAnimatedCover) return;
if (AlwaysAnimate || IsPlayingAnimation)
var shouldAnimate = AlwaysAnimate || IsPlayingAnimation;
if (shouldAnimate)
{
_videoRenderer.Player.SetPause(false);
// Start streaming on first hover/focus if we deferred it in LoadCover.
if (_videoRenderer == null)
{
if (!string.IsNullOrEmpty(_animatedSource))
LoadAnimatedCover(_animatedSource);
}
else
{
_videoRenderer.Player?.SetPause(false);
}
}
else
else if (_videoRenderer != null)
{
_videoRenderer.Player.SetPause(true);
// Tear the stream down entirely on un-hover instead of pausing, so idle
// covers hold no LibVLC/network resources. _isAnimatedCover stays set so a
// re-hover restarts playback.
DisposeRenderer();
SetBitmap(null);
InvalidateVisual();
}
}
private void StopVideo()
/// <summary>
/// Tears down the video renderer without clearing <see cref="_isAnimatedCover"/>,
/// so the cover can restart on a later hover. Contrast with <see cref="StopVideo"/>,
/// which fully resets animated state when the source itself changes.
/// </summary>
private void DisposeRenderer()
{
if (_videoRenderer != null)
{
@ -467,10 +489,15 @@ public partial class Cover : UserControl
_videoRenderer.Dispose();
_videoRenderer = null;
}
_isAnimatedCover = false;
_receivedFirstFrame = false;
}
private void StopVideo()
{
DisposeRenderer();
_isAnimatedCover = false;
}
// ── Rendering ────────────────────────────────────────────────────────
public override void Render(DrawingContext context)

View file

@ -67,16 +67,17 @@
<Grid ColumnDefinitions="Auto,*,Auto">
<!-- Buttons (left side) -->
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="12">
<!-- Install button (not installed) -->
<Button Command="{Binding InstallCommand}"
IsVisible="{Binding !IsInstalled}"
IsEnabled="{Binding CanInstall}"
Classes="Primary Large">
<!-- Install SplitButton (not installed). Flyout assigned in code-behind. -->
<SplitButton x:Name="InstallSplitButton"
Command="{Binding InstallCommand}"
IsVisible="{Binding !IsInstalled}"
IsEnabled="{Binding CanInstall}"
Classes="Primary Large">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="Install" FontWeight="SemiBold" IsVisible="{Binding !IsInstalling}" />
<TextBlock Text="Installing..." IsVisible="{Binding IsInstalling}" />
</StackPanel>
</Button>
</SplitButton>
<!-- Play / Update / Stop SplitButton (installed) -->
<SplitButton x:Name="PlaySplitButton"
@ -100,7 +101,7 @@
IsVisible="{Binding ShowPlayLabel}">
<TextBlock Text="▶" FontWeight="Bold" IsVisible="{Binding !IsStarting}" />
<TextBlock Text="Play" FontWeight="SemiBold" IsVisible="{Binding !IsStarting}" />
<TextBlock Text="Starting..." IsVisible="{Binding IsStarting}" />
<TextBlock Text="Starting" IsVisible="{Binding IsStarting}" />
</StackPanel>
<!-- Running: overlapping "Running" and "Stop" labels -->
@ -116,32 +117,7 @@
</StackPanel>
</Panel>
</Panel>
<SplitButton.Flyout>
<MenuFlyout Placement="BottomEdgeAlignedLeft">
<MenuItem Header="Play Without Updating"
Command="{Binding PlayOrStopCommand}"
IsVisible="{Binding IsUpdateAvailable}" />
<Separator IsVisible="{Binding IsUpdateAvailable}" />
<MenuItem Header="Browse Files"
Command="{Binding BrowseFilesCommand}" />
<MenuItem Header="View Manual"
Command="{Binding OpenFirstManualCommand}"
IsVisible="{Binding HasManuals}" />
<MenuItem Header="Modify"
Command="{Binding ModifyCommand}" />
<Separator />
<MenuItem Header="Verify Files"
Command="{Binding VerifyFilesCommand}"
IsEnabled="{Binding !IsVerifyingFiles}" />
<MenuItem Header="Uninstall"
Command="{Binding UninstallCommand}"
IsEnabled="{Binding !IsUninstalling}" />
<MenuItem Header="Remove from Library"
Command="{Binding RemoveFromLibraryCommand}"
IsEnabled="{Binding IsInLibrary}" />
</MenuFlyout>
</SplitButton.Flyout>
<!-- Flyout assigned in code-behind (consolidated game menu). -->
</SplitButton>
<!-- Debug Scripts button (when script debugging is enabled and installed) -->

View file

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

View file

@ -1,16 +1,12 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vmComponents="using:LANCommander.Launcher.ViewModels.Components"
xmlns:converters="using:LANCommander.Launcher.Converters"
xmlns:controls="using:LANCommander.Launcher.Controls"
xmlns:local="using:LANCommander.Launcher.Views.Components"
x:Class="LANCommander.Launcher.Views.Components.GenreCarouselButton"
x:Class="LANCommander.Launcher.Views.Components.GenreCarouselButton"
x:DataType="vmComponents:GenreCarouselButtomViewModel"
ClipToBounds="False">
<UserControl.Resources>
<converters:FilePathToBitmapConverter x:Key="FilePathToBitmapConverter" />
</UserControl.Resources>
<UserControl.Styles>
<Style Selector="Button.GenreCarouselButton">
<Setter Property="Padding" Value="0" />
@ -53,7 +49,8 @@
</Border>
<!-- Hero background image -->
<Image Source="{Binding BackgroundPath, Converter={StaticResource FilePathToBitmapConverter}, ConverterParameter=h150}"
<Image controls:AsyncImage.Source="{Binding BackgroundPath}"
controls:AsyncImage.DecodeHeight="150"
Stretch="UniformToFill"
IsVisible="{Binding HasBackground}" />

View file

@ -1,16 +1,12 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vmComponents="using:LANCommander.Launcher.ViewModels.Components"
xmlns:converters="using:LANCommander.Launcher.Converters"
xmlns:controls="using:LANCommander.Launcher.Controls"
xmlns:local="using:LANCommander.Launcher.Views.Components"
x:Class="LANCommander.Launcher.Views.Components.HeroCard"
x:DataType="vmComponents:GameItemViewModel"
ClipToBounds="False">
<UserControl.Resources>
<converters:FilePathToBitmapConverter x:Key="FilePathToBitmapConverter" />
</UserControl.Resources>
<UserControl.Styles>
<Style Selector="Button.HeroCard">
<Setter Property="Padding" Value="0" />
@ -41,18 +37,20 @@
CommandParameter="{Binding $parent[local:HeroCard].CommandParameter}">
<Panel>
<!-- Background: hero image -->
<Image Source="{Binding HeroPath, Converter={StaticResource FilePathToBitmapConverter}, ConverterParameter=full}"
<Image controls:AsyncImage.Source="{Binding HeroPath}"
controls:AsyncImage.DecodeWidth="480"
Stretch="UniformToFill"
IsVisible="{Binding HeroPath, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!-- Fallback: cover blurred as background when no hero -->
<!-- Fallback background when no hero: approximate a blur by decoding the
cover tiny (32px) and letting the GPU upscale it smoothly. This is a
static texture, unlike a live BlurEffect which re-renders every scroll
frame and was the main source of depot scroll jank. -->
<Panel IsVisible="{Binding HeroPath, Converter={x:Static StringConverters.IsNullOrEmpty}}">
<Image Source="{Binding CoverPath, Converter={StaticResource FilePathToBitmapConverter}}"
Stretch="UniformToFill">
<Image.Effect>
<BlurEffect Radius="20" />
</Image.Effect>
</Image>
<Image controls:AsyncImage.Source="{Binding CoverPath}"
controls:AsyncImage.DecodeWidth="32"
RenderOptions.BitmapInterpolationMode="HighQuality"
Stretch="UniformToFill" />
<Border Background="#55000000" />
</Panel>
@ -67,7 +65,7 @@
</Border>
<!-- Logo (bottom-left) -->
<Image Source="{Binding LogoPath, Converter={StaticResource FilePathToBitmapConverter}, ConverterParameter=full}"
<Image controls:AsyncImage.Source="{Binding LogoPath}"
HorizontalAlignment="Left" VerticalAlignment="Bottom"
MaxWidth="190" MaxHeight="72"
Margin="14,0,0,14"

View file

@ -27,7 +27,8 @@
</Border>
<!-- Navigation Buttons -->
<StackPanel DockPanel.Dock="Top" Margin="8,8,8,0">
<StackPanel DockPanel.Dock="Top" Margin="8,8,8,0"
IsVisible="{Binding AreUserLibrariesEnabled}">
<Button Command="{Binding ShowDepotCommand}"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"

View file

@ -52,9 +52,8 @@
</Border>
<!-- Footer -->
<Border Padding="20,12,20,16">
<Border Padding="20,12,20,16" Classes="DialogFooter">
<Button Content="Cancel"
Width="90"
HorizontalAlignment="Right"
Click="Cancel_Click" />
</Border>

View file

@ -3,17 +3,12 @@
xmlns:sys="clr-namespace:System;assembly=System.Runtime"
xmlns:vm="using:LANCommander.Launcher.ViewModels"
xmlns:vmComponents="using:LANCommander.Launcher.ViewModels.Components"
xmlns:converters="using:LANCommander.Launcher.Converters"
xmlns:components="using:LANCommander.Launcher.Views.Components"
xmlns:controls="using:LANCommander.Launcher.Controls"
x:Class="LANCommander.Launcher.Views.GameDetailView"
x:DataType="vm:GameDetailViewModel"
>
<UserControl.Resources>
<converters:FilePathToBitmapConverter x:Key="FilePathToBitmapConverter" />
</UserControl.Resources>
<UserControl.Styles>
<Style Selector="Panel:focus-visible > Border#FocusBorder">
<Setter Property="IsVisible" Value="True" />
@ -37,7 +32,8 @@
IsHitTestVisible="False"
IsVisible="{Binding BackgroundPath, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<Panel>
<Image Source="{Binding BackgroundPath, Converter={StaticResource FilePathToBitmapConverter}, ConverterParameter=full}"
<Image controls:AsyncImage.Source="{Binding BackgroundPath}"
controls:AsyncImage.DecodeWidth="1600"
Stretch="UniformToFill">
<Image.Effect>
<BlurEffect Radius="24" />
@ -48,7 +44,8 @@
</Border>
<!-- Background image -->
<Image Source="{Binding BackgroundPath, Converter={StaticResource FilePathToBitmapConverter}, ConverterParameter=full}"
<Image controls:AsyncImage.Source="{Binding BackgroundPath}"
controls:AsyncImage.DecodeWidth="1600"
Stretch="UniformToFill"
IsVisible="{Binding BackgroundPath, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
@ -99,7 +96,7 @@
VerticalAlignment="Bottom"
MaxWidth="{Binding Bounds.Width, RelativeSource={RelativeSource AncestorType=Border}, Converter={StaticResource MultiplyConverter}, ConverterParameter=0.33}"
MaxHeight="{Binding Bounds.Height, RelativeSource={RelativeSource AncestorType=Border}, Converter={StaticResource MultiplyConverter}, ConverterParameter=0.5}">
<Image Source="{Binding LogoPath, Converter={StaticResource FilePathToBitmapConverter}, ConverterParameter=full}"
<Image controls:AsyncImage.Source="{Binding LogoPath}"
Stretch="Uniform"
HorizontalAlignment="Left"
VerticalAlignment="Bottom" />
@ -155,12 +152,20 @@
<Image Source="{Binding ImageSource}"
Stretch="UniformToFill" />
</Border>
<!-- Video: muted autoplay, click to open lightbox -->
<Border CornerRadius="4" ClipToBounds="True"
Background="Black"
IsVisible="{Binding IsVideo}">
<InlineVideoPlayer VideoPath="{Binding Path}" />
</Border>
<!-- Video: muted autoplay, click to open lightbox.
A ContentControl whose Content is the video path (null
for screenshots) only realizes its template when non-null,
so screenshots never instantiate an InlineVideoPlayer/libvlc. -->
<ContentControl Content="{Binding VideoPath}">
<ContentControl.ContentTemplate>
<DataTemplate x:DataType="sys:String">
<Border CornerRadius="4" ClipToBounds="True"
Background="Black">
<InlineVideoPlayer VideoPath="{Binding}" />
</Border>
</DataTemplate>
</ContentControl.ContentTemplate>
</ContentControl>
<!-- Focus indicator -->
<Border Name="FocusBorder"
CornerRadius="4"

View file

@ -143,7 +143,9 @@ public partial class GameDetailView : UserControl
{
Type = m.IsVideo ? LightboxItemType.Video : LightboxItemType.Image,
Path = m.Path,
ImageSource = m.ImageSource,
// The carousel bitmap is a downscaled thumbnail; let the lightbox
// load the full-resolution image from Path for fullscreen display.
ImageSource = m.IsVideo ? m.ImageSource : null,
});
}
@ -153,12 +155,11 @@ public partial class GameDetailView : UserControl
if (tappedVm.IsVideo)
{
// Find the InlineVideoPlayer inside the tapped panel
var videoBorder = panel.Children
.OfType<Decorator>()
.FirstOrDefault(d => d.Child is InlineVideoPlayer);
// Find the InlineVideoPlayer inside the tapped panel. It lives inside a
// ContentControl's lazily-realized template, so search descendants.
var player = panel.GetVisualDescendants().OfType<InlineVideoPlayer>().FirstOrDefault();
if (videoBorder?.Child is InlineVideoPlayer player)
if (player != null)
{
inlinePlayer = player;
videoStartTimeMs = player.CurrentTimeMs;

View file

@ -28,6 +28,7 @@
<components:CoverButton
Width="{Binding $parent[local:GamesGridView].CoverWidth}"
Height="{Binding $parent[local:GamesGridView].CoverHeight}"
controls:GameContextMenu.IsEnabled="True"
Command="{Binding $parent[ItemsRepeater].((vm:GamesCollectionViewModel)DataContext).ViewGameDetailsCommand}"
CommandParameter="{Binding}" />
</DataTemplate>

Some files were not shown because too many files have changed in this diff Show more