diff --git a/electron/downloads/downloader.ts b/electron/downloads/downloader.ts index 0725302..552fd1a 100644 --- a/electron/downloads/downloader.ts +++ b/electron/downloads/downloader.ts @@ -4,8 +4,8 @@ */ import { promises as fs } from "fs" import { createHash } from "crypto" -import { dirname } from "path" -import { pathExists, ensureDir, safeUnlink } from "./fs-utils" +import { dirname, join } from "path" +import { pathExists, ensureDir, safeUnlink, removeDir } from "./fs-utils" import { extractZip } from "./zip-extractor" import { getLogger } from "../file-logger" @@ -98,10 +98,14 @@ export async function downloadMod(options: DownloadOptions): Promise isPreloaderDll(name)) + let hasPreloader = coreEntries.some(name => isPreloaderDll(name)) + + // If not found in root, check subdirectories + if (!hasPreloader) { + for (const entry of coreEntries) { + const subPath = join(coreDir, entry) + try { + const stat = await fs.stat(subPath) + if (stat.isDirectory()) { + const subEntries = await fs.readdir(subPath) + if (subEntries.some(name => isPreloaderDll(name))) { + hasPreloader = true + break + } + } + } catch { + // Ignore errors reading subdirectories + } + } + } + if (!hasPreloader) { missing.push("BepInEx Preloader DLL") } diff --git a/electron/launch/bepinex-bootstrap.ts b/electron/launch/bepinex-bootstrap.ts index 969df35..eba80d7 100644 --- a/electron/launch/bepinex-bootstrap.ts +++ b/electron/launch/bepinex-bootstrap.ts @@ -363,22 +363,31 @@ export async function copyBepInExToProfile( bootstrapRoot: string, profileRoot: string ): Promise { - console.log(`[BepInExBootstrap] Copying BepInEx to profile: ${profileRoot}`) + console.log(`[BepInExBootstrap] Checking BepInEx setup for profile: ${profileRoot}`) await ensureDir(profileRoot) const packRoot = await resolvePackRoot(bootstrapRoot) - // Copy BepInEx folder - const bepInExSrc = join(packRoot, "BepInEx") + // Check if BepInEx folder exists at all const bepInExDest = join(profileRoot, "BepInEx") + const bepInExExists = await pathExists(bepInExDest) - if (await pathExists(bepInExSrc)) { - await copyDirectory(bepInExSrc, bepInExDest) + if (bepInExExists) { + console.log(`[BepInExBootstrap] BepInEx folder already exists in profile, skipping copy to preserve mod structure`) } else { - throw new Error(`BepInEx folder not found in pack root ${packRoot}`) + console.log(`[BepInExBootstrap] BepInEx folder missing, copying from bootstrap`) + // Copy BepInEx folder (only on first setup) + const bepInExSrc = join(packRoot, "BepInEx") + + if (await pathExists(bepInExSrc)) { + await copyDirectory(bepInExSrc, bepInExDest) + console.log(`[BepInExBootstrap] BepInEx folder copied from bootstrap`) + } else { + throw new Error(`BepInEx folder not found in pack root ${packRoot}`) + } } - // Copy root Doorstop files (winhttp.dll, doorstop_config.ini, etc.) + // Always copy/update root Doorstop files (winhttp.dll, doorstop_config.ini, etc.) const entries = await fs.readdir(packRoot, { withFileTypes: true }) for (const entry of entries) { @@ -389,5 +398,5 @@ export async function copyBepInExToProfile( } } - console.log(`[BepInExBootstrap] BepInEx copied to profile from ${packRoot}`) + console.log(`[BepInExBootstrap] BepInEx setup complete for profile`) } diff --git a/electron/launch/launcher.ts b/electron/launch/launcher.ts index b52a35b..8a14402 100644 --- a/electron/launch/launcher.ts +++ b/electron/launch/launcher.ts @@ -79,6 +79,7 @@ export interface LaunchResult { /** * Finds the BepInEx Preloader DLL in the profile's BepInEx/core directory + * Searches both directly in core/ and in subdirectories (e.g., core/BepInEx-BepInExPack/) */ async function findPreloaderDll(profileRoot: string): Promise { const coreDir = join(profileRoot, "BepInEx", "core") @@ -88,13 +89,36 @@ async function findPreloaderDll(profileRoot: string): Promise { } const coreEntries = await fs.readdir(coreDir) + + // First check directly in core directory (flat structure) const preloaderFile = coreEntries.find(name => isPreloaderDll(name)) - if (!preloaderFile) { - throw new Error(`BepInEx Preloader DLL not found in ${coreDir}`) + if (preloaderFile) { + console.log(`[Launcher] Found preloader DLL in core root: ${preloaderFile}`) + return join(coreDir, preloaderFile) + } + + // Check subdirectories (namespaced structure: BepInEx/core//) + console.log(`[Launcher] No preloader DLL in core root, checking subdirectories...`) + for (const entry of coreEntries) { + const subPath = join(coreDir, entry) + try { + const stat = await fs.stat(subPath) + if (stat.isDirectory()) { + const subEntries = await fs.readdir(subPath) + const subPreloader = subEntries.find(name => isPreloaderDll(name)) + if (subPreloader) { + const fullPath = join(subPath, subPreloader) + console.log(`[Launcher] Found preloader DLL in subdirectory: ${entry}/${subPreloader}`) + return fullPath + } + } + } catch { + // Ignore errors reading subdirectories + } } - return join(coreDir, preloaderFile) + throw new Error(`BepInEx Preloader DLL not found in ${coreDir} or its subdirectories`) } /** @@ -218,10 +242,29 @@ async function validateProfileArtifacts(profileRoot: string): Promise isPreloaderDll(name)) + let hasPreloader = coreEntries.some(name => isPreloaderDll(name)) + + // If not found in root, check subdirectories + if (!hasPreloader) { + for (const entry of coreEntries) { + const subPath = join(coreDir, entry) + try { + const stat = await fs.stat(subPath) + if (stat.isDirectory()) { + const subEntries = await fs.readdir(subPath) + if (subEntries.some(name => isPreloaderDll(name))) { + hasPreloader = true + break + } + } + } catch { + // Ignore errors reading subdirectories + } + } + } if (!hasPreloader) { - return "Profile is missing BepInEx Preloader DLL in BepInEx/core/" + return "Profile is missing BepInEx Preloader DLL in BepInEx/core/ or its subdirectories" } return null // All validations passed diff --git a/electron/profiles/mod-installer.ts b/electron/profiles/mod-installer.ts index 67d6203..f63fde2 100644 --- a/electron/profiles/mod-installer.ts +++ b/electron/profiles/mod-installer.ts @@ -6,6 +6,7 @@ import { promises as fs } from "fs" import { join } from "path" import { ensureDir, copyFile, pathExists, removeDir } from "../downloads/fs-utils" import { resolveGamePaths, type PathSettings } from "../downloads/path-resolver" +import { getLogger } from "../file-logger" /** * Recursively copies all files and directories from source to destination @@ -16,13 +17,13 @@ import { resolveGamePaths, type PathSettings } from "../downloads/path-resolver" */ async function copyDirectory(srcDir: string, destDir: string): Promise { await ensureDir(destDir) - + const entries = await fs.readdir(srcDir, { withFileTypes: true }) - + for (const entry of entries) { const srcPath = join(srcDir, entry.name) const destPath = join(destDir, entry.name) - + if (entry.isDirectory()) { await copyDirectory(srcPath, destPath) } else { @@ -31,13 +32,67 @@ async function copyDirectory(srcDir: string, destDir: string): Promise { } } +/** + * Resolves the effective root directory for mod installation + * Handles nested top-level folder zips (e.g., SomeFolder/BepInEx/...) + * + * Algorithm: + * 1. If /BepInEx exists => return extractedModPath + * 2. Else check immediate children for one with BepInEx => return that child + * 3. Else return extractedModPath + * + * @param extractedModPath - Path to extracted mod folder + * @returns The effective root path to use for installation + */ +async function resolveEffectiveRoot(extractedModPath: string): Promise { + const logger = getLogger() + + // Check if BepInEx exists at the top level + const topLevelBepInEx = join(extractedModPath, "BepInEx") + if (await pathExists(topLevelBepInEx)) { + logger.info(`effectiveRoot: using extractedModPath (BepInEx at top level)`, { effectiveRoot: extractedModPath }) + return extractedModPath + } + + // Check immediate children for BepInEx + try { + const entries = await fs.readdir(extractedModPath, { withFileTypes: true }) + const directories = entries + .filter(entry => entry.isDirectory()) + .sort((a, b) => a.name.localeCompare(b.name)) + + for (const dir of directories) { + const childPath = join(extractedModPath, dir.name) + const childBepInEx = join(childPath, "BepInEx") + if (await pathExists(childBepInEx)) { + logger.info(`effectiveRoot: using nested folder (BepInEx found in child)`, { + effectiveRoot: childPath, + childFolder: dir.name + }) + return childPath + } + } + } catch (error) { + logger.warn(`Failed to scan for nested BepInEx folders`, { error, extractedModPath }) + } + + // No BepInEx found anywhere - use extractedModPath as fallback + logger.info(`effectiveRoot: using extractedModPath (no BepInEx structure found)`, { + effectiveRoot: extractedModPath + }) + return extractedModPath +} + /** * Installs a mod to a profile by copying extracted files - * + * * Profile structure: * - /BepInEx/plugins// + * - /BepInEx/patchers// + * - /BepInEx/core// + * - /BepInEx/monomod// * - /BepInEx/config/ - * + * * @param extractedModPath - Path to extracted mod folder * @param profileRoot - Root path for the profile * @param modId - Mod identifier (author-modname) @@ -48,47 +103,75 @@ export async function installModToProfile( profileRoot: string, modId: string ): Promise<{ filesCopied: number; pluginPath: string; configPath: string }> { + const logger = getLogger() + // Ensure extracted mod exists if (!(await pathExists(extractedModPath))) { throw new Error(`Extracted mod not found at: ${extractedModPath}`) } - + + // Resolve effective root (handles nested folder zips) + const effectiveRoot = await resolveEffectiveRoot(extractedModPath) + // Ensure profile root exists await ensureDir(profileRoot) - + // Set up profile BepInEx structure const profileBepInExRoot = join(profileRoot, "BepInEx") const profilePluginsRoot = join(profileBepInExRoot, "plugins") const profileConfigRoot = join(profileBepInExRoot, "config") - const modPluginPath = join(profilePluginsRoot, modId) - + const profilePatchersRoot = join(profileBepInExRoot, "patchers") + const profileCoreRoot = join(profileBepInExRoot, "core") + const profileMonomodRoot = join(profileBepInExRoot, "monomod") + await ensureDir(profilePluginsRoot) await ensureDir(profileConfigRoot) - - // Check if extracted mod has BepInEx structure - const extractedBepInEx = join(extractedModPath, "BepInEx") - const hasBepInExStructure = await pathExists(extractedBepInEx) - + await ensureDir(profilePatchersRoot) + await ensureDir(profileCoreRoot) + await ensureDir(profileMonomodRoot) + + // Check if effective root has BepInEx structure + const effectiveRootBepInEx = join(effectiveRoot, "BepInEx") + const hasBepInExStructure = await pathExists(effectiveRootBepInEx) + let filesCopied = 0 - + const sourceRoutes: string[] = [] + if (hasBepInExStructure) { - // Mod has BepInEx/plugins and BepInEx/config structure - const extractedPlugins = join(extractedBepInEx, "plugins") - const extractedConfig = join(extractedBepInEx, "config") - - // Copy plugins - if (await pathExists(extractedPlugins)) { - await copyDirectory(extractedPlugins, modPluginPath) - filesCopied += await countFiles(extractedPlugins) + logger.info(`Installing mod ${modId} with BepInEx structure`, { effectiveRoot, hasBepInExStructure }) + + // Define all possible routes + const routes = [ + { name: "plugins", src: join(effectiveRootBepInEx, "plugins"), dest: join(profilePluginsRoot, modId) }, + { name: "patchers", src: join(effectiveRootBepInEx, "patchers"), dest: join(profilePatchersRoot, modId) }, + { name: "core", src: join(effectiveRootBepInEx, "core"), dest: join(profileCoreRoot, modId) }, + { name: "monomod", src: join(effectiveRootBepInEx, "monomod"), dest: join(profileMonomodRoot, modId) }, + ] + + // Copy namespaced routes (plugins, patchers, core, monomod) + for (const route of routes) { + if (await pathExists(route.src)) { + sourceRoutes.push(route.name) + // Remove existing destination folder for idempotency + if (await pathExists(route.dest)) { + await removeDir(route.dest) + } + await copyDirectory(route.src, route.dest) + const count = await countFiles(route.src) + filesCopied += count + logger.info(`Copied ${route.name} route for ${modId}`, { src: route.src, dest: route.dest, files: count }) + } } - + // Copy config files (merge into profile config, not isolated by mod) + const extractedConfig = join(effectiveRootBepInEx, "config") if (await pathExists(extractedConfig)) { + sourceRoutes.push("config") const configEntries = await fs.readdir(extractedConfig, { withFileTypes: true }) for (const entry of configEntries) { const srcPath = join(extractedConfig, entry.name) const destPath = join(profileConfigRoot, entry.name) - + if (entry.isDirectory()) { await copyDirectory(srcPath, destPath) } else { @@ -96,24 +179,50 @@ export async function installModToProfile( } filesCopied++ } + logger.info(`Merged config files for ${modId}`, { src: extractedConfig, dest: profileConfigRoot }) } + + logger.info(`Mod ${modId} installation complete`, { + effectiveRoot, + sourceRoutes, + filesCopied + }) } else { // Mod files are at root (no BepInEx structure) - copy everything to plugins/ - await copyDirectory(extractedModPath, modPluginPath) - filesCopied = await countFiles(extractedModPath) + logger.info(`Installing mod ${modId} without BepInEx structure (fallback to plugins)`, { effectiveRoot }) + + const modPluginPath = join(profilePluginsRoot, modId) + // Remove existing destination folder for idempotency + if (await pathExists(modPluginPath)) { + await removeDir(modPluginPath) + } + await copyDirectory(effectiveRoot, modPluginPath) + filesCopied = await countFiles(effectiveRoot) + + logger.info(`Mod ${modId} installation complete (fallback)`, { + effectiveRoot, + dest: modPluginPath, + filesCopied + }) } - + return { filesCopied, - pluginPath: modPluginPath, + pluginPath: join(profilePluginsRoot, modId), configPath: profileConfigRoot, } } /** - * Uninstalls a mod from a profile by deleting its plugin folder + * Uninstalls a mod from a profile by deleting its folders * Config files are left alone (they may be shared by other mods) - * + * + * Removes: + * - BepInEx/plugins/ + * - BepInEx/patchers/ + * - BepInEx/core/ + * - BepInEx/monomod/ + * * @param profileRoot - Root path for the profile * @param modId - Mod identifier (author-modname) * @returns Number of files removed @@ -122,15 +231,29 @@ export async function uninstallModFromProfile( profileRoot: string, modId: string ): Promise { - const modPluginPath = join(profileRoot, "BepInEx", "plugins", modId) - - if (!(await pathExists(modPluginPath))) { - return 0 + const logger = getLogger() + + const paths = [ + join(profileRoot, "BepInEx", "plugins", modId), + join(profileRoot, "BepInEx", "patchers", modId), + join(profileRoot, "BepInEx", "core", modId), + join(profileRoot, "BepInEx", "monomod", modId), + ] + + let filesRemoved = 0 + const removedPaths: string[] = [] + + for (const path of paths) { + if (await pathExists(path)) { + const count = await countFiles(path) + await fs.rm(path, { recursive: true, force: true }) + filesRemoved += count + removedPaths.push(path) + } } - - const filesRemoved = await countFiles(modPluginPath) - await fs.rm(modPluginPath, { recursive: true, force: true }) - + + logger.info(`Uninstalled mod ${modId}`, { filesRemoved, removedPaths }) + return filesRemoved } diff --git a/electron/trpc/data/profiles.ts b/electron/trpc/data/profiles.ts index 583e101..299f5ce 100644 --- a/electron/trpc/data/profiles.ts +++ b/electron/trpc/data/profiles.ts @@ -5,6 +5,7 @@ import { profile } from "../../db/schema" import { eq, and, asc } from "drizzle-orm" import { randomUUID } from "crypto" import { isoToEpoch } from "./games" +import { getLogger } from "../../file-logger" type Profile = { id: string @@ -20,6 +21,58 @@ function rowToProfile(r: typeof profile.$inferSelect): Profile { } } +/** + * Resolves the active profile ID for a game, with fallback to default profile. + * Returns profileId or null if neither exists. + */ +export async function resolveActiveProfileId(gameId: string): Promise { + const db = getDb() + const logger = getLogger() + + logger.debug(`[resolveActiveProfileId] Resolving for game: ${gameId}`) + + // First try active profile + const activeRows = await db + .select({ id: profile.id }) + .from(profile) + .where(and(eq(profile.gameId, gameId), eq(profile.isActive, true))) + .limit(1) + + if (activeRows[0]) { + logger.info(`[resolveActiveProfileId] ✓ Found active profile: ${activeRows[0].id}`) + return activeRows[0].id + } + + logger.debug(`[resolveActiveProfileId] No active profile found, trying default...`) + + // Fall back to default profile + const defaultRows = await db + .select({ id: profile.id }) + .from(profile) + .where(and(eq(profile.gameId, gameId), eq(profile.isDefault, true))) + .limit(1) + + if (defaultRows[0]) { + logger.info(`[resolveActiveProfileId] ✓ Found default profile: ${defaultRows[0].id}`) + return defaultRows[0].id + } + + logger.error(`[resolveActiveProfileId] ❌ No active or default profile exists for game: ${gameId}`) + + // List all profiles for this game for debugging + const allProfiles = await db + .select({ id: profile.id, name: profile.name, isActive: profile.isActive, isDefault: profile.isDefault }) + .from(profile) + .where(eq(profile.gameId, gameId)) + + logger.error(`[resolveActiveProfileId] Total profiles for this game: ${allProfiles.length}`) + if (allProfiles.length > 0) { + logger.error(`[resolveActiveProfileId] Profiles:`, { profiles: allProfiles }) + } + + return null +} + export const dataProfilesRouter = t.router({ /** List all profiles for a game */ list: publicProcedure diff --git a/electron/trpc/router.ts b/electron/trpc/router.ts index 795798e..2d1014e 100644 --- a/electron/trpc/router.ts +++ b/electron/trpc/router.ts @@ -6,7 +6,7 @@ import { t, publicProcedure } from "./trpc" import { dataRouter } from "./data" import { searchPackages, getPackage } from "../thunderstore/search" import { resolveDependencies, resolveDependenciesRecursive } from "../thunderstore/dependencies" -import { clearCatalog, getCategories, getCatalogStatus } from "../thunderstore/catalog" +import { clearCatalog, getCategories, getCatalogStatus, ensureCatalogUpToDate, resolvePackagesByOwnerName } from "../thunderstore/catalog" import { clearAllCache } from "../thunderstore/cache" import { getDownloadManager } from "../downloads/manager" import { setPathSettings, getPathSettings } from "../downloads/settings-state" @@ -18,6 +18,10 @@ import { launchGame, type LaunchMode } from "../launch/launcher" import { cleanupInjected } from "../launch/injection-tracker" import { checkBaseDependencies, installBaseDependencies } from "../launch/base-dependencies" import { getLogger } from "../file-logger" +import { resolveActiveProfileId } from "./data/profiles" +import { getDb } from "../db" +import { profileMod } from "../db/schema" +import { eq, or, and } from "drizzle-orm" /** * Desktop/filesystem procedures @@ -407,7 +411,7 @@ const profilesRouter = t.router({ .mutation(async ({ input }) => { const settings = getPathSettings() const paths = resolveGamePaths(input.gameId, settings) - const profileRoot = `${paths.profilesRoot}/${input.profileId}` + const profileRoot = join(paths.profilesRoot, input.profileId) const result = await installModToProfile( input.extractedPath, @@ -438,7 +442,7 @@ const profilesRouter = t.router({ .mutation(async ({ input }) => { const settings = getPathSettings() const paths = resolveGamePaths(input.gameId, settings) - const profileRoot = `${paths.profilesRoot}/${input.profileId}` + const profileRoot = join(paths.profilesRoot, input.profileId) const filesRemoved = await uninstallModFromProfile( profileRoot, @@ -465,7 +469,7 @@ const profilesRouter = t.router({ .mutation(async ({ input }) => { const settings = getPathSettings() const paths = resolveGamePaths(input.gameId, settings) - const profileRoot = `${paths.profilesRoot}/${input.profileId}` + const profileRoot = join(paths.profilesRoot, input.profileId) const filesRemoved = await resetProfileBepInEx(profileRoot) @@ -576,7 +580,7 @@ const launchRouter = t.router({ .mutation(async ({ input }) => { const settings = getPathSettings() const paths = resolveGamePaths(input.gameId, settings) - const profileRoot = `${paths.profilesRoot}/${input.profileId}` + const profileRoot = join(paths.profilesRoot, input.profileId) const result = await launchGame({ gameId: input.gameId, @@ -608,20 +612,134 @@ const launchRouter = t.router({ /** * Check if base dependencies are installed for a profile + * Verifies both filesystem (doorstop DLLs, BepInEx/core, etc.) and DB state (profile_mod entry) */ checkBaseDependencies: publicProcedure .input( z.object({ gameId: z.string(), - profileId: z.string(), + packageIndexUrl: z.string(), + modloaderPackage: z.object({ + owner: z.string(), + name: z.string(), + rootFolder: z.string(), + }).optional(), }) ) .query(async ({ input }) => { + const logger = getLogger() + logger.info(`[checkBaseDependencies] Starting check for game: ${input.gameId}`) + logger.debug(`[checkBaseDependencies] Package index URL: ${input.packageIndexUrl}`) + logger.debug(`[checkBaseDependencies] Modloader package: ${input.modloaderPackage ? `${input.modloaderPackage.owner}-${input.modloaderPackage.name}` : 'default (BepInEx-BepInExPack)'}`) + + const missing: string[] = [] + + // Step 1: Resolve active profile + logger.debug(`[checkBaseDependencies] Step 1: Resolving active profile...`) + const activeProfileId = await resolveActiveProfileId(input.gameId) + + if (!activeProfileId) { + logger.error(`[checkBaseDependencies] ❌ No active or default profile found for game: ${input.gameId}`) + return { + needsInstall: true, + missing: ["No active profile for this game"], + } + } + + logger.info(`[checkBaseDependencies] ✓ Active profile resolved: ${activeProfileId}`) + + // Step 2: Check filesystem for base dependencies + logger.debug(`[checkBaseDependencies] Step 2: Checking filesystem...`) const settings = getPathSettings() const paths = resolveGamePaths(input.gameId, settings) - const profileRoot = `${paths.profilesRoot}/${input.profileId}` + const profileRoot = join(paths.profilesRoot, activeProfileId) + logger.debug(`[checkBaseDependencies] Profile root path: ${profileRoot}`) + + const filesystemCheck = await checkBaseDependencies(profileRoot) + + if (filesystemCheck.missing.length > 0) { + logger.warn(`[checkBaseDependencies] ❌ Filesystem check failed`, { missing: filesystemCheck.missing }) + missing.push(...filesystemCheck.missing) + } else { + logger.info(`[checkBaseDependencies] ✓ Filesystem check passed`) + } + + // Step 3: Resolve modloader package identity from catalog + logger.debug(`[checkBaseDependencies] Step 3: Resolving modloader package from catalog...`) + const packageOwner = input.modloaderPackage?.owner || "BepInEx" + const packageName = input.modloaderPackage?.name || "BepInExPack" + const packageId = `${packageOwner}-${packageName}` + logger.debug(`[checkBaseDependencies] Package ID: ${packageId}`) + + let modloaderUuid4: string | undefined = undefined + + try { + await ensureCatalogUpToDate(input.packageIndexUrl) + const packageMap = resolvePackagesByOwnerName(input.packageIndexUrl, [packageId]) + const pkg = packageMap.get(packageId) + modloaderUuid4 = pkg?.uuid4 + + if (modloaderUuid4) { + logger.info(`[checkBaseDependencies] ✓ Resolved UUID4: ${modloaderUuid4}`) + } else { + logger.warn(`[checkBaseDependencies] ⚠ Package ${packageId} not found in catalog (will fall back to packageId for DB check)`) + } + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error) + logger.warn(`[checkBaseDependencies] ⚠ Failed to resolve ${packageId} from catalog: ${errorMsg}`) + // Continue without uuid4 - DB check will fall back to packageId only + } + + // Step 4: Check DB for modloader entry in profile_mod + logger.debug(`[checkBaseDependencies] Step 4: Checking DB for modloader registration...`) + const db = getDb() + const modIds = [modloaderUuid4, packageId].filter((id): id is string => !!id) + logger.debug(`[checkBaseDependencies] Checking for modIds in profile_mod: [${modIds.join(', ')}]`) + + if (modIds.length > 0) { + const dbRows = await db + .select({ modId: profileMod.modId }) + .from(profileMod) + .where( + and( + eq(profileMod.profileId, activeProfileId), + or(...modIds.map(id => eq(profileMod.modId, id))) + ) + ) + .limit(1) + + if (!dbRows[0]) { + logger.error(`[checkBaseDependencies] ❌ Modloader not found in profile_mod table`) + logger.error(`[checkBaseDependencies] Profile: ${activeProfileId}`) + logger.error(`[checkBaseDependencies] Expected mod IDs: [${modIds.join(', ')}]`) + + // Query all mods in this profile for debugging + const allMods = await db + .select({ modId: profileMod.modId }) + .from(profileMod) + .where(eq(profileMod.profileId, activeProfileId)) + + logger.error(`[checkBaseDependencies] Found ${allMods.length} mods in profile: [${allMods.map(m => m.modId).join(', ')}]`) + + missing.push(`Modloader package (${packageId}) not registered in profile`) + } else { + logger.info(`[checkBaseDependencies] ✓ Modloader found in profile_mod: ${dbRows[0].modId}`) + } + } else { + logger.error(`[checkBaseDependencies] ❌ No mod IDs to check (both uuid4 and packageId unavailable)`) + } - return await checkBaseDependencies(profileRoot) + // Final result + if (missing.length > 0) { + logger.error(`[checkBaseDependencies] ❌ Check FAILED with ${missing.length} missing items`, { missing }) + } else { + logger.info(`[checkBaseDependencies] ✅ Check PASSED - all dependencies installed`) + } + + return { + needsInstall: missing.length > 0, + missing, + } }), /** @@ -643,7 +761,7 @@ const launchRouter = t.router({ .mutation(async ({ input }) => { const settings = getPathSettings() const paths = resolveGamePaths(input.gameId, settings) - const profileRoot = `${paths.profilesRoot}/${input.profileId}` + const profileRoot = join(paths.profilesRoot, input.profileId) return await installBaseDependencies( input.gameId, diff --git a/src/components/download-bridge.tsx b/src/components/download-bridge.tsx index fc13642..e5b99f9 100644 --- a/src/components/download-bridge.tsx +++ b/src/components/download-bridge.tsx @@ -10,12 +10,9 @@ import { useEffect, useRef } from "react" import { toast } from "sonner" import { useDownloadStore } from "@/store/download-store" -// Keep store imports for imperative getState() inside IPC callbacks -import { useSettingsStore } from "@/store/settings-store" -import { useProfileStore } from "@/store/profile-store" -import { useModManagementStore } from "@/store/mod-management-store" -import { useSettingsData } from "@/data" +import { useSettingsData, settingsService, profileService, modService, dataKeys } from "@/data" import { trpc } from "@/lib/trpc" +import { queryClient } from "@/lib/query-client" type DownloadUpdateEvent = { downloadId: string @@ -72,6 +69,8 @@ export function DownloadBridge() { // Single IPC subscription (with StrictMode guard to prevent double-subscription) const subscriptionActiveRef = useRef(false) + // Track in-flight auto-installs to prevent duplicates if completion event fires twice + const autoInstallInFlightRef = useRef(new Set()) useEffect(() => { // Guard against StrictMode double-mounting @@ -132,57 +131,81 @@ export function DownloadBridge() { const task = useDownloadStore.getState().getTask(event.downloadId) if (!task) return - // Check if auto-install is enabled (read from store directly to get latest value) - const autoInstallEnabled = useSettingsStore.getState().global.autoInstallMods - if (autoInstallEnabled && event.result?.extractedPath) { - // Get active profile for this game - const activeProfileId = useProfileStore.getState().activeProfileIdByGame[task.gameId] - - if (activeProfileId) { - // Check if mod is already installed - const isAlreadyInstalled = useModManagementStore.getState().isModInstalled(activeProfileId, task.modId) - - if (!isAlreadyInstalled) { - try { - // Auto-install the mod - const result = await installModMutation.mutateAsync({ - gameId: task.gameId, - profileId: activeProfileId, - modId: task.modId, - author: task.modAuthor, - name: task.modName, - version: task.modVersion, - extractedPath: event.result.extractedPath, - }) - - // Mark as installed in state - useModManagementStore.getState().installMod(activeProfileId, task.modId, task.modVersion) + // Check in-flight guard to prevent duplicate auto-installs + if (autoInstallInFlightRef.current.has(event.downloadId)) { + return + } - // Show success toast - toast.success(`${task.modName} installed`, { - description: `v${task.modVersion} - ${result.filesCopied} files copied to profile`, - }) - } catch (error) { - // Show error toast if auto-install fails - const message = error instanceof Error ? error.message : "Unknown error" - toast.error(`Auto-install failed: ${task.modName}`, { - description: message, + try { + // Check if auto-install is enabled (read from service to get latest DB value) + const globalSettings = await settingsService.getGlobal() + const autoInstallEnabled = globalSettings.autoInstallMods + + if (autoInstallEnabled && event.result?.extractedPath) { + // Get active profile for this game + const activeProfile = await profileService.getActive(task.gameId) + + if (activeProfile) { + // Check if mod is already installed + const isAlreadyInstalled = await modService.isInstalled(activeProfile.id, task.modId) + + if (!isAlreadyInstalled) { + // Mark as in-flight + autoInstallInFlightRef.current.add(event.downloadId) + + try { + // Auto-install the mod + const result = await installModMutation.mutateAsync({ + gameId: task.gameId, + profileId: activeProfile.id, + modId: task.modId, + author: task.modAuthor, + name: task.modName, + version: task.modVersion, + extractedPath: event.result.extractedPath, + }) + + // Mark as installed in state (DB mode) + await modService.install(activeProfile.id, task.modId, task.modVersion) + + // Invalidate React Query cache so UI updates in DB mode + await queryClient.invalidateQueries({ queryKey: dataKeys.modManagement }) + + // Show success toast + toast.success(`${task.modName} installed`, { + description: `v${task.modVersion} - ${result.filesCopied} files copied to profile`, + }) + } catch (error) { + // Show error toast if auto-install fails + const message = error instanceof Error ? error.message : "Unknown error" + toast.error(`Auto-install failed: ${task.modName}`, { + description: message, + }) + } finally { + // Clear in-flight flag + autoInstallInFlightRef.current.delete(event.downloadId) + } + } else { + // Mod already installed, just show download success + toast.success(`${task.modName} downloaded`, { + description: `v${task.modVersion} - already installed`, }) } } else { - // Mod already installed, just show download success + // No active profile, show download success toast.success(`${task.modName} downloaded`, { - description: `v${task.modVersion} - already installed`, + description: `v${task.modVersion} - no active profile`, }) } } else { - // No active profile, show download success + // Auto-install disabled or no extracted path, show regular download success toast.success(`${task.modName} downloaded`, { - description: `v${task.modVersion} - no active profile`, + description: `v${task.modVersion} is ready to install`, }) } - } else { - // Auto-install disabled or no extracted path, show regular download success + } catch (error) { + // If service layer fails, show error (shouldn't happen normally) + console.error("Auto-install check failed:", error) toast.success(`${task.modName} downloaded`, { description: `v${task.modVersion} is ready to install`, }) diff --git a/src/components/features/game-dashboard.tsx b/src/components/features/game-dashboard.tsx index 9c3892b..a7c7c39 100644 --- a/src/components/features/game-dashboard.tsx +++ b/src/components/features/game-dashboard.tsx @@ -202,9 +202,12 @@ export function GameDashboard() { try { // Check if base dependencies are installed + const modloaderPackage = selectedGameId ? getModloaderPackageForGame(selectedGameId) : null + const depsCheck = await trpcUtils.launch.checkBaseDependencies.fetch({ gameId: selectedGameId, - profileId: activeProfileId, + packageIndexUrl, + modloaderPackage: modloaderPackage || undefined, }) if (depsCheck.needsInstall) { @@ -215,8 +218,6 @@ export function GameDashboard() { } // Dependencies are installed, proceed with launch - const modloaderPackage = selectedGameId ? getModloaderPackageForGame(selectedGameId) : null - const result = await launchMutation.mutateAsync({ gameId: selectedGameId, profileId: activeProfileId, diff --git a/src/components/features/mods-library.tsx b/src/components/features/mods-library.tsx index 2f121ae..67742cb 100644 --- a/src/components/features/mods-library.tsx +++ b/src/components/features/mods-library.tsx @@ -940,9 +940,12 @@ export function ModsLibrary() { try { // Check if base dependencies are installed + const modloaderPackage = selectedGameId ? getModloaderPackageForGame(selectedGameId) : null + const depsCheck = await trpcUtils.launch.checkBaseDependencies.fetch({ gameId: selectedGameId, - profileId: activeProfileId, + packageIndexUrl, + modloaderPackage: modloaderPackage || undefined, }) if (depsCheck.needsInstall) { @@ -952,8 +955,6 @@ export function ModsLibrary() { return } - const modloaderPackage = selectedGameId ? getModloaderPackageForGame(selectedGameId) : null - const result = await launchMutation.mutateAsync({ gameId: selectedGameId, profileId: activeProfileId, diff --git a/src/hooks/use-download-actions.ts b/src/hooks/use-download-actions.ts index 682dcb5..5c8c6ee 100644 --- a/src/hooks/use-download-actions.ts +++ b/src/hooks/use-download-actions.ts @@ -6,7 +6,7 @@ import { useCallback } from "react" import { toast } from "sonner" import { trpc } from "@/lib/trpc" import { useDownloadStore } from "@/store/download-store" -import { useSettingsStore } from "@/store/settings-store" +import { useSettingsData } from "@/data" export function useDownloadActions() { const enqueueMutation = trpc.downloads.enqueue.useMutation() @@ -14,6 +14,9 @@ export function useDownloadActions() { const pauseMutation = trpc.downloads.pause.useMutation() const resumeMutation = trpc.downloads.resume.useMutation() + // Use reactive settings from data layer (works in both Zustand and DB mode) + const { global: globalSettings } = useSettingsData() + const startDownload = useCallback( (params: { gameId: string @@ -26,10 +29,9 @@ export function useDownloadActions() { }) => { const downloadId = `${params.gameId}:${params.modId}:${params.modVersion}` - // Read settings at the moment of action (no effect needed) - const settings = useSettingsStore.getState().global - const preferredCdn = settings.preferredThunderstoreCdn - const downloadCacheEnabled = settings.downloadCacheEnabled + // Read settings at the moment of action from reactive data + const preferredCdn = globalSettings.preferredThunderstoreCdn + const downloadCacheEnabled = globalSettings.downloadCacheEnabled // Optimistically add task to store useDownloadStore.getState()._addTask({ @@ -64,7 +66,7 @@ export function useDownloadActions() { ignoreCache: !downloadCacheEnabled, }) }, - [enqueueMutation] + [enqueueMutation, globalSettings] ) const pauseDownload = useCallback( diff --git a/src/main.tsx b/src/main.tsx index daed72c..a1c686b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -36,7 +36,9 @@ createRoot(rootEl).render( - + + + {import.meta.env.DEV && (