From b32c0d79d96b4e654f1e1cf5194d9879ef9c6c8d Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Sat, 7 Feb 2026 08:22:54 +0000 Subject: [PATCH 1/4] Initial plan From adee8f19060a1edaec0b518c4a097a397b81ccfe Mon Sep 17 00:00:00 2001 From: "anthropic-code-agent[bot]" <242468646+Claude@users.noreply.github.com> Date: Sat, 7 Feb 2026 08:26:39 +0000 Subject: [PATCH 2/4] Implement generic BepInEx install rules with r2modmanPlus behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit implements the complete specification for fixing BepInEx mod installation to behave like r2modmanPlus, addressing the "sometimes missing files" issue and adding support for all BepInEx routes. Changes made: A) Extraction cache correctness (downloader.ts) - Added marker-based cache hit validation using _extracted.ok file - Cache is now valid ONLY when both extractPath and marker exist - Cleans up stale/partial extractions before re-extracting - Writes marker after successful extraction - Removes marker on extraction failure - Added detailed logging for cache hit/miss scenarios B) Effective root resolution (mod-installer.ts) - Added resolveEffectiveRoot() function to handle nested folder zips - Checks for BepInEx at top level first - Falls back to searching immediate children for BepInEx folders - Supports common Thunderstore layout: SomeFolder/BepInEx/... - Uses deterministic sorting for consistent behavior C) Generic BepInEx route handling (mod-installer.ts) - Now installs all BepInEx routes when present: * plugins/ (namespaced under modId) * patchers/ (namespaced under modId) * core/ (namespaced under modId) * monomod/ (namespaced under modId) * config/ (merged, not namespaced) - Added idempotency: removes destination folders before copying - Config files are merged into shared config directory - Fallback behavior maintained for mods without BepInEx structure - Comprehensive logging of which routes were found and installed D) Uninstall logic updated (mod-installer.ts) - Now removes all namespaced route folders: * BepInEx/plugins/ * BepInEx/patchers/ * BepInEx/core/ * BepInEx/monomod/ - Config files remain untouched (shared between mods) - Logs removed paths and file counts E) Deterministic logging throughout - effectiveRoot resolution decision and reason - Cache hit/miss reasons (extractPath exists, marker exists, ignoreCache) - Which source routes existed in the mod - Which destination routes were written - File counts for each operation - Uninstall operations with paths and counts This fixes the primary issue of "sometimes missing files" caused by partial extraction cache and ensures all BepInEx route types are installed correctly. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- electron/downloads/downloader.ts | 53 ++++++-- electron/profiles/mod-installer.ts | 201 +++++++++++++++++++++++------ 2 files changed, 204 insertions(+), 50 deletions(-) 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 { 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 } From 2e9929cb55efe93d5bf3662856975f4b7247031f Mon Sep 17 00:00:00 2001 From: "daniel.chim" Date: Sat, 7 Feb 2026 21:25:33 +0800 Subject: [PATCH 3/4] fix: auto install mods from downloads --- src/components/download-bridge.tsx | 113 +++++++++++++++++------------ src/hooks/use-download-actions.ts | 14 ++-- src/main.tsx | 4 +- 3 files changed, 79 insertions(+), 52 deletions(-) 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/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 && ( From e2cd1c362270d229411781bae15083f8fdc298df Mon Sep 17 00:00:00 2001 From: "daniel.chim" Date: Sat, 7 Feb 2026 22:57:40 +0800 Subject: [PATCH 4/4] feat: bepinex failed to launch --- electron/launch/base-dependencies.ts | 24 +++- electron/launch/bepinex-bootstrap.ts | 25 ++-- electron/launch/launcher.ts | 53 +++++++- electron/trpc/data/profiles.ts | 53 ++++++++ electron/trpc/router.ts | 136 +++++++++++++++++++-- src/components/features/game-dashboard.tsx | 7 +- src/components/features/mods-library.tsx | 7 +- 7 files changed, 275 insertions(+), 30 deletions(-) diff --git a/electron/launch/base-dependencies.ts b/electron/launch/base-dependencies.ts index 1e3a964..27d31ea 100644 --- a/electron/launch/base-dependencies.ts +++ b/electron/launch/base-dependencies.ts @@ -83,10 +83,30 @@ export async function checkBaseDependencies( if (!(await pathExists(coreDir))) { missing.push("BepInEx/core directory") } else { - // Check for preloader DLL + // Check for preloader DLL (can be directly in core or in subdirectories) try { const coreEntries = await fs.readdir(coreDir) - const hasPreloader = coreEntries.some(name => 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/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/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,