From b21efc63d0c0811e1b8ae07e084b5433595f312b Mon Sep 17 00:00:00 2001 From: Pat Hartl Date: Sat, 27 Jun 2026 11:51:55 -0500 Subject: [PATCH] Track tools per game Ref #414 --- LANCommander.Launcher.Data/DatabaseContext.cs | 8 + ...452_AddPerGameToolInstallState.Designer.cs | 985 ++++++++++++++++++ ...260627164452_AddPerGameToolInstallState.cs | 199 ++++ .../DatabaseContextModelSnapshot.cs | 95 +- LANCommander.Launcher.Data/Models/Game.cs | 1 + LANCommander.Launcher.Data/Models/GameTool.cs | 25 + LANCommander.Launcher.Data/Models/Tool.cs | 9 +- LANCommander.Launcher.Services/GameService.cs | 24 +- .../Import/Importers/ToolImporter.cs | 12 +- .../InstallService.cs | 57 +- LANCommander.Launcher.Services/ToolService.cs | 73 +- .../Components/GameActionBarViewModel.cs | 10 +- .../ViewModels/GameDetailViewModel.cs | 15 +- 13 files changed, 1405 insertions(+), 108 deletions(-) create mode 100644 LANCommander.Launcher.Data/Migrations/20260627164452_AddPerGameToolInstallState.Designer.cs create mode 100644 LANCommander.Launcher.Data/Migrations/20260627164452_AddPerGameToolInstallState.cs create mode 100644 LANCommander.Launcher.Data/Models/GameTool.cs diff --git a/LANCommander.Launcher.Data/DatabaseContext.cs b/LANCommander.Launcher.Data/DatabaseContext.cs index 952f7487..4ec0a9c4 100644 --- a/LANCommander.Launcher.Data/DatabaseContext.cs +++ b/LANCommander.Launcher.Data/DatabaseContext.cs @@ -139,6 +139,14 @@ namespace LANCommander.Launcher.Data gr => gr.HasOne().WithMany().HasForeignKey("GameId") ); + builder.Entity() + .HasMany(g => g.Tools) + .WithMany(t => t.Games) + .UsingEntity( + gt => gt.HasOne(x => x.Tool).WithMany(t => t.GameTools).HasForeignKey(x => x.ToolId).OnDelete(DeleteBehavior.Cascade), + gt => gt.HasOne(x => x.Game).WithMany(g => g.GameTools).HasForeignKey(x => x.GameId).OnDelete(DeleteBehavior.Cascade), + gt => gt.HasKey(x => new { x.GameId, x.ToolId })); + builder.Entity() .HasMany(g => g.DependentGames) .WithOne(g => g.BaseGame) diff --git a/LANCommander.Launcher.Data/Migrations/20260627164452_AddPerGameToolInstallState.Designer.cs b/LANCommander.Launcher.Data/Migrations/20260627164452_AddPerGameToolInstallState.Designer.cs new file mode 100644 index 00000000..104911a6 --- /dev/null +++ b/LANCommander.Launcher.Data/Migrations/20260627164452_AddPerGameToolInstallState.Designer.cs @@ -0,0 +1,985 @@ +// +using System; +using LANCommander.Launcher.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace LANCommander.Launcher.Data.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260627164452_AddPerGameToolInstallState")] + partial class AddPerGameToolInstallState + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.9"); + + modelBuilder.Entity("CategoryGame", b => + { + b.Property("CategoriesId") + .HasColumnType("TEXT"); + + b.Property("GamesId") + .HasColumnType("TEXT"); + + b.HasKey("CategoriesId", "GamesId"); + + b.HasIndex("GamesId"); + + b.ToTable("CategoryGame"); + }); + + modelBuilder.Entity("CollectionGame", b => + { + b.Property("CollectionId") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.HasKey("CollectionId", "GameId"); + + b.HasIndex("GameId"); + + b.ToTable("CollectionGame"); + }); + + modelBuilder.Entity("GameDeveloper", b => + { + b.Property("DeveloperId") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.HasKey("DeveloperId", "GameId"); + + b.HasIndex("GameId"); + + b.ToTable("GameDeveloper"); + }); + + modelBuilder.Entity("GameGenre", b => + { + b.Property("GamesId") + .HasColumnType("TEXT"); + + b.Property("GenresId") + .HasColumnType("TEXT"); + + b.HasKey("GamesId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("GameGenre"); + }); + + modelBuilder.Entity("GamePlatform", b => + { + b.Property("GamesId") + .HasColumnType("TEXT"); + + b.Property("PlatformsId") + .HasColumnType("TEXT"); + + b.HasKey("GamesId", "PlatformsId"); + + b.HasIndex("PlatformsId"); + + b.ToTable("GamePlatform"); + }); + + modelBuilder.Entity("GamePublisher", b => + { + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("PublisherId") + .HasColumnType("TEXT"); + + b.HasKey("GameId", "PublisherId"); + + b.HasIndex("PublisherId"); + + b.ToTable("GamePublisher"); + }); + + modelBuilder.Entity("GameRedistributable", b => + { + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("RedistributableId") + .HasColumnType("TEXT"); + + b.HasKey("GameId", "RedistributableId"); + + b.HasIndex("RedistributableId"); + + b.ToTable("GameRedistributable"); + }); + + modelBuilder.Entity("GameTag", b => + { + b.Property("GamesId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("GamesId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("GameTag"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Category", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.ToTable("Categories"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Collection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Collections"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Company", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Companies"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Engine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Engines"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BaseGameId") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("EngineId") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("InstallDirectory") + .HasColumnType("TEXT"); + + b.Property("Installed") + .HasColumnType("INTEGER"); + + b.Property("InstalledOn") + .HasColumnType("TEXT"); + + b.Property("InstalledVersion") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("ReleasedOn") + .HasColumnType("TEXT"); + + b.Property("Singleplayer") + .HasColumnType("INTEGER"); + + b.Property("SortTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BaseGameId"); + + b.HasIndex("EngineId"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.GameExternalId", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("GameExternalIds"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.GameTool", b => + { + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("ToolId") + .HasColumnType("TEXT"); + + b.Property("InstallDirectory") + .HasColumnType("TEXT"); + + b.Property("Installed") + .HasColumnType("INTEGER"); + + b.Property("InstalledOn") + .HasColumnType("TEXT"); + + b.Property("InstalledVersion") + .HasColumnType("TEXT"); + + b.HasKey("GameId", "ToolId"); + + b.HasIndex("ToolId"); + + b.ToTable("GameTool"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Libraries"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Media", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Crc32") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("FileId") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("MimeType") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Media"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.MultiplayerMode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("MinPlayers") + .HasColumnType("INTEGER"); + + b.Property("NetworkProtocol") + .HasColumnType("INTEGER"); + + b.Property("Spectators") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("MultiplayerModes"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Platform", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Platforms"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.PlaySession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("PlaySessions"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Redistributable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Redistributables"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Tool", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("LatestVersion") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Tools"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("CreatedOn") + .HasColumnType("TEXT"); + + b.Property("ImportedOn") + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.Property("UserName") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("LibraryGame", b => + { + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.HasKey("GameId", "LibraryId"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryGame"); + }); + + modelBuilder.Entity("CategoryGame", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Category", null) + .WithMany() + .HasForeignKey("CategoriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GamesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionGame", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Collection", null) + .WithMany() + .HasForeignKey("CollectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GameDeveloper", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Company", null) + .WithMany() + .HasForeignKey("DeveloperId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GameGenre", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GamesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GamePlatform", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GamesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Platform", null) + .WithMany() + .HasForeignKey("PlatformsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GamePublisher", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Company", null) + .WithMany() + .HasForeignKey("PublisherId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GameRedistributable", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Redistributable", null) + .WithMany() + .HasForeignKey("RedistributableId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GameTag", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GamesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Category", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Category", "Parent") + .WithMany("Children") + .HasForeignKey("ParentId"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Game", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", "BaseGame") + .WithMany("DependentGames") + .HasForeignKey("BaseGameId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("LANCommander.Launcher.Data.Models.Engine", "Engine") + .WithMany("Games") + .HasForeignKey("EngineId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("BaseGame"); + + b.Navigation("Engine"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.GameExternalId", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", "Game") + .WithMany("ExternalIds") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.GameTool", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", "Game") + .WithMany("GameTools") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Tool", "Tool") + .WithMany("GameTools") + .HasForeignKey("ToolId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + + b.Navigation("Tool"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Library", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.User", "User") + .WithOne("Library") + .HasForeignKey("LANCommander.Launcher.Data.Models.Library", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Media", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", "Game") + .WithMany("Media") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("LANCommander.Launcher.Data.Models.User", "User") + .WithOne("Avatar") + .HasForeignKey("LANCommander.Launcher.Data.Models.Media", "UserId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.MultiplayerMode", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", "Game") + .WithMany("MultiplayerModes") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.PlaySession", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", "Game") + .WithMany("PlaySessions") + .HasForeignKey("GameId"); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("LibraryGame", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", null) + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Category", b => + { + b.Navigation("Children"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Engine", b => + { + b.Navigation("Games"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Game", b => + { + b.Navigation("DependentGames"); + + b.Navigation("ExternalIds"); + + b.Navigation("GameTools"); + + b.Navigation("Media"); + + b.Navigation("MultiplayerModes"); + + b.Navigation("PlaySessions"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Tool", b => + { + b.Navigation("GameTools"); + }); + + modelBuilder.Entity("LANCommander.Launcher.Data.Models.User", b => + { + b.Navigation("Avatar"); + + b.Navigation("Library"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/LANCommander.Launcher.Data/Migrations/20260627164452_AddPerGameToolInstallState.cs b/LANCommander.Launcher.Data/Migrations/20260627164452_AddPerGameToolInstallState.cs new file mode 100644 index 00000000..4491ab0f --- /dev/null +++ b/LANCommander.Launcher.Data/Migrations/20260627164452_AddPerGameToolInstallState.cs @@ -0,0 +1,199 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace LANCommander.Launcher.Data.Migrations +{ + /// + public partial class AddPerGameToolInstallState : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_GameTool_Games_GamesId", + table: "GameTool"); + + migrationBuilder.DropForeignKey( + name: "FK_GameTool_Tools_ToolsId", + table: "GameTool"); + + migrationBuilder.RenameColumn( + name: "ToolsId", + table: "GameTool", + newName: "ToolId"); + + migrationBuilder.RenameColumn( + name: "GamesId", + table: "GameTool", + newName: "GameId"); + + migrationBuilder.RenameIndex( + name: "IX_GameTool_ToolsId", + table: "GameTool", + newName: "IX_GameTool_ToolId"); + + migrationBuilder.AddColumn( + name: "InstallDirectory", + table: "GameTool", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Installed", + table: "GameTool", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "InstalledOn", + table: "GameTool", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "InstalledVersion", + table: "GameTool", + type: "TEXT", + nullable: true); + + // Tool install state was previously tracked globally on the Tools table. Migrate it onto + // the join row for the game the tool was actually installed under (the game whose install + // directory matches the tool's recorded directory) before the old columns are dropped. + migrationBuilder.Sql(@" + UPDATE GameTool + SET + Installed = 1, + InstallDirectory = (SELECT t.InstallDirectory FROM Tools t WHERE t.Id = GameTool.ToolId), + InstalledVersion = (SELECT t.InstalledVersion FROM Tools t WHERE t.Id = GameTool.ToolId), + InstalledOn = (SELECT t.InstalledOn FROM Tools t WHERE t.Id = GameTool.ToolId) + WHERE EXISTS ( + SELECT 1 + FROM Tools t + JOIN Games g ON g.Id = GameTool.GameId + WHERE t.Id = GameTool.ToolId + AND t.Installed = 1 + AND t.InstallDirectory IS NOT NULL + AND t.InstallDirectory = g.InstallDirectory + );"); + + migrationBuilder.DropColumn( + name: "InstallDirectory", + table: "Tools"); + + migrationBuilder.DropColumn( + name: "Installed", + table: "Tools"); + + migrationBuilder.DropColumn( + name: "InstalledOn", + table: "Tools"); + + migrationBuilder.DropColumn( + name: "InstalledVersion", + table: "Tools"); + + migrationBuilder.AddForeignKey( + name: "FK_GameTool_Games_GameId", + table: "GameTool", + column: "GameId", + principalTable: "Games", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_GameTool_Tools_ToolId", + table: "GameTool", + column: "ToolId", + principalTable: "Tools", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_GameTool_Games_GameId", + table: "GameTool"); + + migrationBuilder.DropForeignKey( + name: "FK_GameTool_Tools_ToolId", + table: "GameTool"); + + migrationBuilder.DropColumn( + name: "InstallDirectory", + table: "GameTool"); + + migrationBuilder.DropColumn( + name: "Installed", + table: "GameTool"); + + migrationBuilder.DropColumn( + name: "InstalledOn", + table: "GameTool"); + + migrationBuilder.DropColumn( + name: "InstalledVersion", + table: "GameTool"); + + migrationBuilder.RenameColumn( + name: "ToolId", + table: "GameTool", + newName: "ToolsId"); + + migrationBuilder.RenameColumn( + name: "GameId", + table: "GameTool", + newName: "GamesId"); + + migrationBuilder.RenameIndex( + name: "IX_GameTool_ToolId", + table: "GameTool", + newName: "IX_GameTool_ToolsId"); + + migrationBuilder.AddColumn( + name: "InstallDirectory", + table: "Tools", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Installed", + table: "Tools", + type: "INTEGER", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "InstalledOn", + table: "Tools", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "InstalledVersion", + table: "Tools", + type: "TEXT", + nullable: true); + + migrationBuilder.AddForeignKey( + name: "FK_GameTool_Games_GamesId", + table: "GameTool", + column: "GamesId", + principalTable: "Games", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + migrationBuilder.AddForeignKey( + name: "FK_GameTool_Tools_ToolsId", + table: "GameTool", + column: "ToolsId", + principalTable: "Tools", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/LANCommander.Launcher.Data/Migrations/DatabaseContextModelSnapshot.cs b/LANCommander.Launcher.Data/Migrations/DatabaseContextModelSnapshot.cs index fba57e51..c6ed51a6 100644 --- a/LANCommander.Launcher.Data/Migrations/DatabaseContextModelSnapshot.cs +++ b/LANCommander.Launcher.Data/Migrations/DatabaseContextModelSnapshot.cs @@ -137,21 +137,6 @@ namespace LANCommander.Launcher.Data.Migrations b.ToTable("GameTag"); }); - modelBuilder.Entity("GameTool", b => - { - b.Property("GamesId") - .HasColumnType("TEXT"); - - b.Property("ToolsId") - .HasColumnType("TEXT"); - - b.HasKey("GamesId", "ToolsId"); - - b.HasIndex("ToolsId"); - - b.ToTable("GameTool"); - }); - modelBuilder.Entity("LANCommander.Launcher.Data.Models.Category", b => { b.Property("Id") @@ -355,6 +340,33 @@ namespace LANCommander.Launcher.Data.Migrations b.ToTable("GameExternalIds"); }); + modelBuilder.Entity("LANCommander.Launcher.Data.Models.GameTool", b => + { + b.Property("GameId") + .HasColumnType("TEXT"); + + b.Property("ToolId") + .HasColumnType("TEXT"); + + b.Property("InstallDirectory") + .HasColumnType("TEXT"); + + b.Property("Installed") + .HasColumnType("INTEGER"); + + b.Property("InstalledOn") + .HasColumnType("TEXT"); + + b.Property("InstalledVersion") + .HasColumnType("TEXT"); + + b.HasKey("GameId", "ToolId"); + + b.HasIndex("ToolId"); + + b.ToTable("GameTool"); + }); + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Genre", b => { b.Property("Id") @@ -632,18 +644,6 @@ namespace LANCommander.Launcher.Data.Migrations b.Property("ImportedOn") .HasColumnType("TEXT"); - b.Property("InstallDirectory") - .HasColumnType("TEXT"); - - b.Property("Installed") - .HasColumnType("INTEGER"); - - b.Property("InstalledOn") - .HasColumnType("TEXT"); - - b.Property("InstalledVersion") - .HasColumnType("TEXT"); - b.Property("LatestVersion") .HasColumnType("TEXT"); @@ -823,21 +823,6 @@ namespace LANCommander.Launcher.Data.Migrations .IsRequired(); }); - modelBuilder.Entity("GameTool", b => - { - b.HasOne("LANCommander.Launcher.Data.Models.Game", null) - .WithMany() - .HasForeignKey("GamesId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("LANCommander.Launcher.Data.Models.Tool", null) - .WithMany() - .HasForeignKey("ToolsId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - modelBuilder.Entity("LANCommander.Launcher.Data.Models.Category", b => { b.HasOne("LANCommander.Launcher.Data.Models.Category", "Parent") @@ -874,6 +859,25 @@ namespace LANCommander.Launcher.Data.Migrations b.Navigation("Game"); }); + modelBuilder.Entity("LANCommander.Launcher.Data.Models.GameTool", b => + { + b.HasOne("LANCommander.Launcher.Data.Models.Game", "Game") + .WithMany("GameTools") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("LANCommander.Launcher.Data.Models.Tool", "Tool") + .WithMany("GameTools") + .HasForeignKey("ToolId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + + b.Navigation("Tool"); + }); + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Library", b => { b.HasOne("LANCommander.Launcher.Data.Models.User", "User") @@ -952,6 +956,8 @@ namespace LANCommander.Launcher.Data.Migrations b.Navigation("ExternalIds"); + b.Navigation("GameTools"); + b.Navigation("Media"); b.Navigation("MultiplayerModes"); @@ -959,6 +965,11 @@ namespace LANCommander.Launcher.Data.Migrations b.Navigation("PlaySessions"); }); + modelBuilder.Entity("LANCommander.Launcher.Data.Models.Tool", b => + { + b.Navigation("GameTools"); + }); + modelBuilder.Entity("LANCommander.Launcher.Data.Models.User", b => { b.Navigation("Avatar"); diff --git a/LANCommander.Launcher.Data/Models/Game.cs b/LANCommander.Launcher.Data/Models/Game.cs index 3edb106a..9cb55495 100644 --- a/LANCommander.Launcher.Data/Models/Game.cs +++ b/LANCommander.Launcher.Data/Models/Game.cs @@ -44,6 +44,7 @@ namespace LANCommander.Launcher.Data.Models public virtual ICollection? Platforms { get; set; } = new List(); public virtual ICollection? Redistributables { get; set; } = new List(); public virtual ICollection? Tools { get; set; } = new List(); + public virtual ICollection GameTools { get; set; } = new List(); public virtual ICollection? Media { get; set; } = new List(); public virtual ICollection Collections { get; set; } = new List(); public virtual ICollection DependentGames { get; set; } = new List(); diff --git a/LANCommander.Launcher.Data/Models/GameTool.cs b/LANCommander.Launcher.Data/Models/GameTool.cs new file mode 100644 index 00000000..46e85d03 --- /dev/null +++ b/LANCommander.Launcher.Data/Models/GameTool.cs @@ -0,0 +1,25 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace LANCommander.Launcher.Data.Models +{ + /// + /// Join entity between and that tracks the install + /// state of a tool for a specific game. Because a tool can be shared by multiple games and is + /// installed into each game's own directory, the install state must be tracked per game rather + /// than on the tool itself. + /// + [Table("GameTool")] + public class GameTool + { + public Guid GameId { get; set; } + public virtual Game Game { get; set; } + + public Guid ToolId { get; set; } + public virtual Tool Tool { get; set; } + + public bool Installed { get; set; } + public string? InstallDirectory { get; set; } + public string? InstalledVersion { get; set; } + public DateTime? InstalledOn { get; set; } + } +} diff --git a/LANCommander.Launcher.Data/Models/Tool.cs b/LANCommander.Launcher.Data/Models/Tool.cs index 6b46bd0d..872020d6 100644 --- a/LANCommander.Launcher.Data/Models/Tool.cs +++ b/LANCommander.Launcher.Data/Models/Tool.cs @@ -8,13 +8,10 @@ namespace LANCommander.Launcher.Data.Models public string Name { get; set; } public string? Description { get; set; } public string? Notes { get; set; } - - public bool Installed { get; set; } - public string? InstallDirectory { get; set; } - public string? InstalledVersion { get; set; } - public DateTime? InstalledOn { get; set; } + public string? LatestVersion { get; set; } - + public virtual ICollection? Games { get; set; } = new List(); + public virtual ICollection GameTools { get; set; } = new List(); } } diff --git a/LANCommander.Launcher.Services/GameService.cs b/LANCommander.Launcher.Services/GameService.cs index f595bec2..20bb66e6 100644 --- a/LANCommander.Launcher.Services/GameService.cs +++ b/LANCommander.Launcher.Services/GameService.cs @@ -70,29 +70,23 @@ namespace LANCommander.Launcher.Services } } - // Uninstall any tools that were installed alongside this game. Tools are - // installed into the game's directory, so this must happen before the - // install directory is cleared in ClearGameState. - var installedTools = await toolService - .Query(t => t.Installed && t.Games.Any(g => g.Id == game.Id)) - .ToListAsync(); + // 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 tool in installedTools) + foreach (var gameTool in installedTools) { try { - await toolClient.UninstallAsync(game.InstallDirectory, tool.Id); + await toolClient.UninstallAsync(game.InstallDirectory, gameTool.ToolId); - tool.Installed = false; - tool.InstallDirectory = null; - tool.InstalledVersion = null; - tool.InstalledOn = null; - - await toolService.UpdateAsync(tool); + await toolService.SetToolUninstalledAsync(game.Id, gameTool.ToolId); } catch (Exception ex) { - Logger?.LogError(ex, "Could not uninstall tool {ToolId} from game {GameId}", tool.Id, game.Id); + Logger?.LogError(ex, "Could not uninstall tool {ToolId} from game {GameId}", gameTool.ToolId, game.Id); } } diff --git a/LANCommander.Launcher.Services/Import/Importers/ToolImporter.cs b/LANCommander.Launcher.Services/Import/Importers/ToolImporter.cs index f7cf43b9..34aad012 100644 --- a/LANCommander.Launcher.Services/Import/Importers/ToolImporter.cs +++ b/LANCommander.Launcher.Services/Import/Importers/ToolImporter.cs @@ -8,7 +8,6 @@ namespace LANCommander.Launcher.Services.Import.Importers; public class ToolImporter( ToolService toolService, - LibraryService libraryService, ILogger logger) : BaseImporter { public override async Task> GetImportInfoAsync(Tool record, BaseManifest manifest) => @@ -77,9 +76,14 @@ public class ToolImporter( await toolService.UpdateAsync(existing); await UpdateRelationships(importItemInfo.Record); - - if (await libraryService.IsInstalledAsync(existing.Id) && existing.LatestVersion == existing.InstalledVersion) - await ManifestHelper.WriteAsync(importItemInfo.Record, existing.InstallDirectory); + + // Refresh the on-disk manifest for every game this tool is installed for and up to date + // on. Install state is tracked per game because a tool can be shared by several games. + foreach (var gameTool in await toolService.GetInstalledGameToolsAsync(existing.Id)) + { + if (existing.LatestVersion == gameTool.InstalledVersion && !string.IsNullOrEmpty(gameTool.InstallDirectory)) + await ManifestHelper.WriteAsync(importItemInfo.Record, gameTool.InstallDirectory); + } return true; } diff --git a/LANCommander.Launcher.Services/InstallService.cs b/LANCommander.Launcher.Services/InstallService.cs index 9e747c8e..61132cf4 100644 --- a/LANCommander.Launcher.Services/InstallService.cs +++ b/LANCommander.Launcher.Services/InstallService.cs @@ -600,28 +600,22 @@ namespace LANCommander.Launcher.Services 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. + // 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 toolsToRemove = await _toolService - .Query(t => t.Installed && t.Games.Any(g => g.Id == localGame.Id)) - .ToListAsync(); + var installedTools = await _toolService.GetInstalledToolsForGameAsync(localGame.Id); - foreach (var localTool in toolsToRemove.Where(t => !selectedToolIds.Contains(t.Id))) + foreach (var gameTool in installedTools.Where(gt => !selectedToolIds.Contains(gt.ToolId))) { try { - await _toolClient.UninstallAsync(localGame.InstallDirectory, localTool.Id); + await _toolClient.UninstallAsync(localGame.InstallDirectory, gameTool.ToolId); - localTool.Installed = false; - localTool.InstallDirectory = null; - localTool.InstalledVersion = null; - localTool.InstalledOn = null; - - await _toolService.UpdateAsync(localTool); + await _toolService.SetToolUninstalledAsync(localGame.Id, gameTool.ToolId); } catch (Exception ex) { - Logger?.LogError(ex, "Could not uninstall tool {ToolId} from game {GameId}", localTool.Id, localGame.Id); + Logger?.LogError(ex, "Could not uninstall tool {ToolId} from game {GameId}", gameTool.ToolId, localGame.Id); } } @@ -687,7 +681,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 } @@ -871,6 +868,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 { @@ -937,6 +947,8 @@ namespace LANCommander.Launcher.Services currentItem.Status = InstallStatus.Downloading; OnQueueChanged?.Invoke(); + string toolInstallDirectory = null; + try { var planItem = new InstallPlanItem @@ -950,7 +962,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) { @@ -978,7 +990,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) { @@ -1200,14 +1217,6 @@ namespace LANCommander.Launcher.Services } } - private static void UpdateToolState(InstallQueueTool currentItem, Tool localTool, string installDirectory) - { - localTool.InstallDirectory = installDirectory; - localTool.Installed = true; - localTool.InstalledVersion = currentItem.Version; - localTool.InstalledOn ??= DateTime.Now; - } - public async Task Move(IInstallQueueItem currentItem, Game localGame, SDK.Models.Game remoteGame) { using (var operation = Logger.BeginOperation("Moving game {GameTitle} ({GameId}) to {Destination}", localGame.Title, localGame.Id, currentItem.InstallDirectory)) diff --git a/LANCommander.Launcher.Services/ToolService.cs b/LANCommander.Launcher.Services/ToolService.cs index c3057a4e..62fc8330 100644 --- a/LANCommander.Launcher.Services/ToolService.cs +++ b/LANCommander.Launcher.Services/ToolService.cs @@ -1,5 +1,6 @@ -using LANCommander.Launcher.Data; +using LANCommander.Launcher.Data; using LANCommander.Launcher.Data.Models; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; namespace LANCommander.Launcher.Services @@ -8,5 +9,75 @@ namespace LANCommander.Launcher.Services ILogger logger, DatabaseContext dbContext) : BaseDatabaseService(dbContext, logger) { + /// + /// Returns the install-state join rows for every tool currently installed for the given game. + /// The related is eagerly loaded. + /// + public async Task> GetInstalledToolsForGameAsync(Guid gameId) + { + return await Context.Set() + .Include(gt => gt.Tool) + .Where(gt => gt.GameId == gameId && gt.Installed) + .ToListAsync(); + } + + /// + /// Returns the install-state join rows for every game the given tool is installed for. + /// + public async Task> GetInstalledGameToolsAsync(Guid toolId) + { + return await Context.Set() + .Where(gt => gt.ToolId == toolId && gt.Installed) + .ToListAsync(); + } + + public async Task IsToolInstalledForGameAsync(Guid gameId, Guid toolId) + { + return await Context.Set() + .AnyAsync(gt => gt.GameId == gameId && gt.ToolId == toolId && gt.Installed); + } + + /// + /// Records that a tool has been installed for a specific game. Creates the join row if the + /// game/tool association does not yet exist locally. + /// + public async Task SetToolInstalledAsync(Guid gameId, Guid toolId, string installDirectory, string version) + { + var gameTool = await Context.Set() + .FirstOrDefaultAsync(gt => gt.GameId == gameId && gt.ToolId == toolId); + + if (gameTool == null) + { + gameTool = new GameTool { GameId = gameId, ToolId = toolId }; + await Context.Set().AddAsync(gameTool); + } + + gameTool.Installed = true; + gameTool.InstallDirectory = installDirectory; + gameTool.InstalledVersion = version; + gameTool.InstalledOn ??= DateTime.Now; + + await Context.SaveChangesAsync(); + } + + /// + /// Clears the install state for a tool on a specific game without removing the game/tool + /// association. Other games keep their own install state. + /// + public async Task SetToolUninstalledAsync(Guid gameId, Guid toolId) + { + var gameTool = await Context.Set() + .FirstOrDefaultAsync(gt => gt.GameId == gameId && gt.ToolId == toolId); + + if (gameTool == null) + return; + + gameTool.Installed = false; + gameTool.InstallDirectory = null; + gameTool.InstalledVersion = null; + gameTool.InstalledOn = null; + + await Context.SaveChangesAsync(); + } } } diff --git a/LANCommander.Launcher/ViewModels/Components/GameActionBarViewModel.cs b/LANCommander.Launcher/ViewModels/Components/GameActionBarViewModel.cs index 6b45fe74..943780f0 100644 --- a/LANCommander.Launcher/ViewModels/Components/GameActionBarViewModel.cs +++ b/LANCommander.Launcher/ViewModels/Components/GameActionBarViewModel.cs @@ -1062,7 +1062,7 @@ public partial class GameActionBarViewModel : ViewModelBase, IDisposable var localGame = await dbContext.Set() .Include(g => g.DependentGames) - .Include(g => g.Tools) + .Include(g => g.GameTools) .FirstOrDefaultAsync(g => g.Id == GameId); if (localGame == null) @@ -1099,11 +1099,11 @@ public partial class GameActionBarViewModel : ViewModelBase, IDisposable .Where(a => a.Installed) .Select(a => a.Id)); - // Build set of currently installed tool IDs + // Build set of currently installed tool IDs (tracked per game) var installedToolIds = new HashSet( - (localGame.Tools ?? []) - .Where(t => t.Installed) - .Select(t => t.Id)); + (localGame.GameTools ?? []) + .Where(gt => gt.Installed) + .Select(gt => gt.ToolId)); // ── Build options VM ─────────────────────────────────────────────── var optionsVm = new InstallOptionsViewModel(); diff --git a/LANCommander.Launcher/ViewModels/GameDetailViewModel.cs b/LANCommander.Launcher/ViewModels/GameDetailViewModel.cs index 415a9422..13a1013e 100644 --- a/LANCommander.Launcher/ViewModels/GameDetailViewModel.cs +++ b/LANCommander.Launcher/ViewModels/GameDetailViewModel.cs @@ -523,8 +523,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)); } } } @@ -559,17 +559,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; } }