Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 42 additions & 11 deletions electron/downloads/downloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -98,20 +98,39 @@ export async function downloadMod(options: DownloadOptions): Promise<DownloadRes

const logger = getLogger()
const modId = `${author}-${name}@${version}`


// Marker file for cache validation
const markerPath = join(extractPath, "_extracted.ok")

// Check cache hit (if not ignoring cache)
if (!ignoreCache && await pathExists(extractPath)) {
logger.info(`Download cache hit for ${modId}`)
// Cache is valid ONLY if both extractPath and marker exist
if (!ignoreCache && await pathExists(extractPath) && await pathExists(markerPath)) {
logger.info(`Download cache hit for ${modId}`, { extractPath, markerExists: true })
return {
extractedPath: extractPath,
archivePath: archivePath,
bytesTotal: 0,
fromCache: true,
}
}


// Cache invalid or missing - log the reason
const extractPathExists = await pathExists(extractPath)
const markerExists = await pathExists(markerPath)
logger.info(`Cache miss for ${modId} - re-extracting`, {
extractPathExists,
markerExists,
ignoreCache
})

// Clean up any partial extraction before starting
if (extractPathExists) {
logger.info(`Removing stale/partial extraction for ${modId}`)
await removeDir(extractPath)
}

logger.info(`Starting download for ${modId}`, { downloadUrl, archivePath })

// Ensure parent directories exist
await ensureDir(extractPath)
await ensureDir(dirname(archivePath))
Expand Down Expand Up @@ -187,12 +206,15 @@ export async function downloadMod(options: DownloadOptions): Promise<DownloadRes
await fs.rename(tempArchivePath, archivePath)

logger.info(`Download completed for ${modId}, extracting...`, { bytesDownloaded })

// Extract zip
await extractZip(archivePath, extractPath)

logger.info(`Extraction completed for ${modId}`, { extractPath })


// Write marker to indicate successful extraction
await fs.writeFile(markerPath, "ok\n", "utf-8")

logger.info(`Extraction completed for ${modId}`, { extractPath, markerWritten: true })

return {
extractedPath: extractPath,
archivePath: archivePath,
Expand All @@ -204,11 +226,20 @@ export async function downloadMod(options: DownloadOptions): Promise<DownloadRes
// Cleanup on error
await fileHandle.close()
await safeUnlink(tempArchivePath)

// Ensure marker doesn't exist on failure
await safeUnlink(markerPath)
// Optionally clean up partial extraction
await removeDir(extractPath)

throw error
}
} catch (error: unknown) {
// Cleanup temp file on any error
await safeUnlink(tempArchivePath)

// Ensure no marker exists after failure
await safeUnlink(markerPath)

if (error instanceof Error) {
if (error.name === "AbortError" || error.message.includes("aborted")) {
Expand Down
24 changes: 22 additions & 2 deletions electron/launch/base-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
25 changes: 17 additions & 8 deletions electron/launch/bepinex-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,22 +363,31 @@ export async function copyBepInExToProfile(
bootstrapRoot: string,
profileRoot: string
): Promise<void> {
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) {
Expand All @@ -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`)
}
53 changes: 48 additions & 5 deletions electron/launch/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const coreDir = join(profileRoot, "BepInEx", "core")
Expand All @@ -88,13 +89,36 @@ async function findPreloaderDll(profileRoot: string): Promise<string> {
}

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/<modId>/)
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`)
}

/**
Expand Down Expand Up @@ -218,10 +242,29 @@ async function validateProfileArtifacts(profileRoot: string): Promise<string | n
}

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) {
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
Expand Down
Loading