Skip to content
Merged
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
108 changes: 107 additions & 1 deletion src/domain/mods/importModpack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,33 @@ import type { InstalledModCopy, InstallModFailure, InstallModResult, ModReleaseT
export interface ModpackEntry {
modid: string
version: string
/**
* Display name of the copy the pack was exported from, when the manifest carries one. Older packs
* do not, and nothing here may depend on it: it is a label of last resort, never an identifier.
*/
name?: string
}

/**
* The manifest reader (`src/ipc/handlers/modsHandlers.ts`) refuses a mod name longer than this.
* The writer below must never emit one, so the two stay in agreement without being copy-pasted.
*/
export const MAX_MODPACK_MOD_NAME_LENGTH = 256

/**
* Cuts a mod's display name down to the length the manifest reader accepts.
*
* The name rides along for display only: it is never an identifier, and it must never be the
* reason an export fails. A modinfo.json name can run up to 4096 characters, well past the
* reader's cap, so anything over the cap is cut down here rather than left to the reader to
* reject. The cut lands on a UTF-16 code unit boundary that never splits a surrogate pair, so a
* name ending on an astral character (an emoji, say) keeps or drops it whole rather than leaving
* a lone surrogate behind.
*/
export function clampModpackModName(name: string): string {
if (name.length <= MAX_MODPACK_MOD_NAME_LENGTH) return name
const cut = name.slice(0, MAX_MODPACK_MOD_NAME_LENGTH)
return /[\uD800-\uDBFF]$/.test(cut) ? cut.slice(0, -1) : cut
}

/** A mod already in the installation's Mods folder, copied out of wherever it lives. */
Expand Down Expand Up @@ -54,6 +81,11 @@ export type ModpackSkipReason =
| "not-on-moddb"
/** The page exists but publishes no release at all. */
| "no-release"
/**
* The ModDB lookup itself did not answer: a transport failure, not a 404. Unlike
* "not-on-moddb", nothing here says the mod does not exist, so the row must not say so either.
*/
| "lookup-failed"

/** An entry the import will install, with the release it settled on. */
export interface ModpackInstallItem {
Expand Down Expand Up @@ -103,6 +135,12 @@ export interface ModpackPlanInput {
gameVersion: string
/** ModDB detail per modid, for every entry {@link modpackEntriesToResolve} asked for. */
details: ReadonlyMap<string, ModpackModDetail>
/**
* Modids whose lookup did not answer at all, transport failure rather than a clean miss. Absent
* from `details` the same way a genuine 404 is, but the caller has to tell the two apart to
* avoid calling a mod a fork when the database was simply unreachable.
*/
failedModids?: ReadonlySet<string>
}

function installedFor(installed: readonly InstalledModSnapshot[], modid: string): InstalledModSnapshot | undefined {
Expand Down Expand Up @@ -145,6 +183,71 @@ export function modpackDowngrades(entries: readonly ModpackEntry[], installed: r
})
}

/**
* The best name a row of the import table can put on one manifest entry.
*
* Three sources, in falling order of trust: the name the ModDB answered with, the name the exporting
* launcher read off the local modinfo.json, and the modid. The middle one is what makes an entry the
* ModDB cannot resolve readable at all, and it is why the export carries it.
*
* `resolvedName` is read off a plan item, which names an unresolvable entry after its own modid, so
* a resolved name equal to the modid counts as no name and falls through to the local one.
*
* @param entry The manifest entry, with the local name when the pack carries one.
* @param resolvedName The ModDB name, when the lookup answered.
*/
export function modpackRowLabel(entry: ModpackEntry, resolvedName?: string): string {
const resolved = resolvedName?.trim()
if (resolved !== undefined && resolved.length > 0 && resolved !== entry.modid) return resolved

const local = entry.name?.trim()
return local !== undefined && local.length > 0 ? local : entry.modid
}

/** What one row of the import table says will happen to that mod, before anything happens. */
export type ModpackRowStatusKind =
/** Not installed at all: the pack adds it. */
| "new"
/** Installed at an older version than the release the import picked. */
| "update"
/** Installed at a newer version than the release the import picked. */
| "downgrade"
/**
* Installed at the version the import would put there, and still replaced: the copy on disk is
* turned off, or its version string does not match what the manifest asked for. A copy edited by
* hand lands here, and the row has to say so before the edit is overwritten.
*/
| "replace"
| ModpackSkipReason

/** One row's plan, with the versions its wording needs. */
export interface ModpackRowStatus {
kind: ModpackRowStatusKind
/** Version installed now, or null when the mod is new to the installation. */
fromVersion: string | null
/** Version the import would leave behind, or null when it will not install anything. */
toVersion: string | null
}

/**
* Reads one plan item as the sentence its row shows.
*
* Every branch here is already decided by {@link planModpackImport}; this only tells the three ways
* of replacing an installed copy apart, which is the difference between "Update from 1.9.0 to
* 2.0.0" and a silent overwrite of a copy the player edited themselves.
*/
export function modpackRowStatus(item: ModpackPlanItem): ModpackRowStatus {
if (item.decision === "skip") {
return { kind: item.reason, fromVersion: item.fromVersion, toVersion: item.reason === "already-present" ? item.fromVersion : null }
}

const toVersion = item.release.modversion
if (item.fromVersion === null) return { kind: "new", fromVersion: null, toVersion }
if (item.downgrade) return { kind: "downgrade", fromVersion: item.fromVersion, toVersion }

return { kind: compareVersions(toVersion, item.fromVersion) > 0 ? "update" : "replace", fromVersion: item.fromVersion, toVersion }
}

/**
* Picks the release to install for one entry.
*
Expand Down Expand Up @@ -181,7 +284,10 @@ function planEntry(entry: ModpackEntry, input: ModpackPlanInput): ModpackPlanIte
}

const detail = input.details.get(entry.modid)
if (!detail) return { decision: "skip", modid: entry.modid, requestedVersion: entry.version, name: entry.modid, reason: "not-on-moddb", fromVersion }
if (!detail) {
const reason = input.failedModids?.has(entry.modid) ? "lookup-failed" : "not-on-moddb"
return { decision: "skip", modid: entry.modid, requestedVersion: entry.version, name: entry.modid, reason, fromVersion }
}

const release = pickRelease(detail.releases, entry.version, input.gameVersion)
if (!release) {
Expand Down
6 changes: 6 additions & 0 deletions src/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,12 @@ declare global {
type ModpackModEntryType = {
modid: string
version: string
/**
* Display name read off the installed mod's modinfo.json when the pack was
* exported. Absent from every pack exported before #379, so no reader may
* require it.
*/
name?: string
}

type ModpackManifestType = {
Expand Down
7 changes: 6 additions & 1 deletion src/ipc/handlers/modsHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { isJpegBytes, isPngBytes } from "@domain/backgrounds"
import { getErrorMessage, logMessage } from "@src/utils/logManager"
import { renameModArchiveTo, scanInstalledMods } from "@domain/mods/scanInstalled"
import type { ScannedMod } from "@domain/mods/scanInstalled"
import { MAX_MODPACK_MOD_NAME_LENGTH } from "@domain/mods/importModpack"

const MAX_MODPACK_ENTRIES = 2_000

Expand Down Expand Up @@ -59,7 +60,11 @@ function parseModpackManifest(value: unknown): ModpackManifestType {
gameVersion: assertString(value.gameVersion, "modpack game version", 128),
mods: value.mods.map((entry) => {
if (!isRecord(entry)) throw new TypeError("Invalid modpack entry")
return { modid: assertString(entry.modid, "mod id", 256), version: assertString(entry.version, "mod version", 128) }
const parsed = { modid: assertString(entry.modid, "mod id", 256), version: assertString(entry.version, "mod version", 128) }
// Every pack exported before #379 carries modid and version only, so the name is read when it
// is there and never required. A name of the wrong type is still a refusal, like every other
// field: the manifest comes off disk and this is the only place its shape is checked.
return entry.name === undefined ? parsed : { ...parsed, name: assertString(entry.name, "mod name", MAX_MODPACK_MOD_NAME_LENGTH) }
})
}
}
Expand Down
40 changes: 37 additions & 3 deletions src/ipc/handlers/netHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { ipcMain } from "electron"
import { app, ipcMain } from "electron"

import { IPC_CHANNELS } from "../ipcChannels"
import { readCatalogCache, writeCatalogCache } from "@src/ipc/catalogCache"
import { ConcurrencyLimiter } from "@src/ipc/concurrencyLimiter"
import { assertTrustedIpcSender } from "@src/ipc/ipcSecurity"
import { requestBoundedBuffer, requestBoundedText } from "@src/ipc/network"
import { assertAllowedApiUrl, assertAllowedDownloadUrl, getApiUrlMaxBytes, MAX_MODDB_LISTING_RESPONSE_BYTES } from "@src/ipc/validation"
Expand All @@ -17,9 +18,42 @@ function isModCatalogUrl(url: URL): boolean {
return url.hostname === MOD_CATALOG_HOSTNAME && url.pathname === MOD_CATALOG_PATHNAME
}

/**
* Nothing capped how many QUERY_URL calls the renderer could have in flight at once, so a
* burst of mod-detail lookups went straight out as one request per mod with no ceiling.
* The modpack import is the loudest caller: it resolves every entry the folder does not
* already satisfy, and it now does that when the manifest loads instead of when Import is
* clicked, so a 200-mod pack opened 200 sockets at the mod database before the player had
* decided anything. Manage Mods does the same shape of thing, one detail per installed mod.
*
* The bound belongs here rather than in the popup because every caller of this channel
* shares the one remote host, and only the main process sees all of them at once. A queued
* call looks to the renderer exactly like a slow one, a promise that has not settled, which
* is what the popup's "Checking the mod database..." state already renders.
*
* 6 is the per-host connection cap Chrome and Firefox have used for years, so a burst from
* the launcher never puts more on the mod database, which is community-run on modest
* hosting, than an ordinary visit to one of its pages does. It also keeps a big pack quick:
* 200 lookups in lanes of 6 is 34 rounds, a few seconds at a normal response time, rather
* than the minutes a smaller bound would cost.
*
* Queueing cannot trip a request's own timeout: requestBoundedText starts its wall clock
* inside the task, which the limiter does not run until a slot is free.
*/
const QUERY_URL_CONCURRENCY_LIMIT = 6

const queryConcurrency = new ConcurrencyLimiter(QUERY_URL_CONCURRENCY_LIMIT)

// Same reason pathsHandlers.ts shuts its limiters down here: before-quit can preventDefault
// (the config flush in main/index.ts), so a queued lookup could otherwise still be handed a
// slot and start a fresh request while the app is on its way out.
app.on("before-quit", () => {
queryConcurrency.shutdown()
})

/**
* Validates and fetches a bounded API response, applying the per-rule ceiling (see
* API_URL_RULES). The mods-catalog endpoint additionally serves its last good disk-cached
* API_URL_RULES) and taking a slot in {@link queryConcurrency} for the request itself. The mods-catalog endpoint additionally serves its last good disk-cached
* response, with a logged warning, when the fresh fetch fails for any reason (network
* down, ceiling tripped, non-2xx status). Every other endpoint fails as before.
*/
Expand All @@ -29,7 +63,7 @@ export async function queryUrl(url: unknown): Promise<string> {
const isCatalog = isModCatalogUrl(safeUrl)

try {
const text = await requestBoundedText(safeUrl, { maxBytes })
const text = await queryConcurrency.run(() => requestBoundedText(safeUrl, { maxBytes }))
if (isCatalog) {
await writeCatalogCache(safeUrl, text).catch((cacheErr: unknown) => {
logMessage("debug", `[back] [ipc] [ipc/handlers/netHandlers.ts] [QUERY_URL] Failed to write mod catalog cache: ${getErrorMessage(cacheErr)}`)
Expand Down
18 changes: 18 additions & 0 deletions src/renderer/src/features/mods/adapters/importModpack.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
import { clampModpackModName } from "@domain/mods/importModpack"
import type { InstalledModSnapshot, ModpackImportEntryReport, ModpackModDetail } from "@domain/mods/importModpack"

/**
* Builds the manifest an export writes.
*
* The local display name rides along with the modid because the two diverge often (`tradie` is
* Traders Expansion, `sandwich` is Sammiches), and the importing launcher has nothing else to call a
* mod the ModDB cannot resolve. Nothing reads it as an identifier: the modid stays the key, and the
* name is clamped rather than passed through verbatim, so one mod with an oversized modinfo.json
* name can never fail the whole export.
*/
export function toModpackManifest(installation: InstallationType, installedMods: readonly InstalledModType[]): ModpackManifestType {
return {
name: installation.name,
gameVersion: installation.version,
mods: installedMods.map((mod) => ({ modid: mod.modid, version: mod.version, name: clampModpackModName(mod.name) }))
}
}

/** Copies an installed mod into the plain shape the planner reads. */
export function toInstalledModSnapshot(mod: InstalledModType): InstalledModSnapshot {
return { modid: mod.modid, name: mod.name, version: mod.version, path: mod.path, enabled: mod.enabled, assetid: mod._mod?.assetid }
Expand Down
Loading
Loading