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
132 changes: 116 additions & 16 deletions src/main/core/mods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,23 @@ import { httpJson, httpJsonPost, downloadFile } from './net'
import * as events from './events'
import { log } from '../logger'
import { MODDED_TYPES, PLUGIN_TYPES } from '@shared/types'
import { diffUpdates } from '@shared/mods'
import {
diffUpdates,
folderForLoaders,
pickCompatibleVersion,
summariseVersion,
PLUGIN_LOADERS
} from '@shared/mods'
import type { ServerType } from '@shared/types'
import type { InstalledMod, ModEntry, ModrinthHit, ModUpdateReport, MrVersion } from '@shared/mods'
import type {
InstalledMod,
ModEntry,
ModrinthDetail,
ModrinthHit,
ModUpdateReport,
MrVersion,
MrVersionInfo
} from '@shared/mods'

type ModFolder = 'plugins' | 'mods'

Expand Down Expand Up @@ -152,7 +166,7 @@ export async function searchModrinth(id: string, query: string): Promise<Modrint
* plugin family is safe; modded and proxy loaders do not cross-load and stay
* single. `[]` means "do not filter by loader" (an unknown server type).
*/
const PLUGIN_LOADER_FAMILY = ['paper', 'purpur', 'folia', 'spigot', 'bukkit']
const PLUGIN_LOADER_FAMILY = PLUGIN_LOADERS

export function loadersFor(type: ServerType): string[] {
if (PLUGIN_TYPES.includes(type) && !MODDED_TYPES.includes(type)) return PLUGIN_LOADER_FAMILY
Expand Down Expand Up @@ -257,25 +271,111 @@ export async function applyUpdate(id: string, rel: string, versionId: string): P
return newName
}

export async function installModrinth(id: string, projectId: string): Promise<string> {
/** Where a jar goes when the version itself declares no loaders. */
function fallbackFolder(type: ServerType): ModFolder {
return MODDED_TYPES.includes(type) ? 'mods' : 'plugins'
}

const MAX_BODY = 4000

/**
* Full project detail for the browse tab (#47): metadata, links, and a
* compatibility verdict for THIS server.
*
* Versions are fetched unfiltered and matched locally so the UI can tell
* "nothing for your Minecraft version" apart from "nothing for your loader" —
* a server-side filtered query collapses both into an empty list. The loader
* set is `searchLoaders`, the same one browse results were filtered by, so a
* result that is listed can never claim a compatibility the install then
* refuses.
*/
export async function modrinthDetail(id: string, projectId: string): Promise<ModrinthDetail> {
const server = getServer(id)
if (!server) throw new Error('server-not-found')
const loader = MR_LOADER[server.type]
const q =
`${MR}/project/${projectId}/version` +
(loader ? `?loaders=%5B%22${loader}%22%5D` : '') +
(server.mcVersion && server.mcVersion !== 'unknown'
? `${loader ? '&' : '?'}game_versions=%5B%22${server.mcVersion}%22%5D`
: '')
const key = encodeURIComponent(projectId)
const loaders = searchLoaders(server.type)

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const versions = await httpJson<any[]>(q)
const v = versions[0]
if (!v) throw new Error('no-compatible-version')
const [project, versions, members] = await Promise.all([
// eslint-disable-next-line @typescript-eslint/no-explicit-any
httpJson<any>(`${MR}/project/${key}`),
httpJson<MrVersionInfo[]>(`${MR}/project/${key}/version`),
// The author is a separate call; losing it must not lose the whole detail.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
httpJson<any[]>(`${MR}/project/${key}/members`).catch(() => [])
])

const mcVersion =
server.mcVersion && server.mcVersion !== 'unknown' ? server.mcVersion : undefined
const compatible = pickCompatibleVersion(versions, { mcVersion, loaders })
// Context when nothing matches: does the project support this loader at all?
const latestForLoader = pickCompatibleVersion(versions, { loaders })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const file = v.files.find((f: any) => f.primary) ?? v.files[0]
const folder: ModFolder = MODDED_TYPES.includes(server.type) ? 'mods' : 'plugins'
const owner = members.find((m: any) => m.role === 'Owner') ?? members[0]
const body = typeof project.body === 'string' ? project.body : undefined

return {
projectId: project.id ?? projectId,
slug: project.slug ?? projectId,
title: project.title ?? projectId,
description: project.description ?? '',
...(body ? { body: body.length > MAX_BODY ? body.slice(0, MAX_BODY) + '…' : body } : {}),
...(owner?.user?.username ? { author: owner.user.username } : {}),
downloads: project.downloads ?? 0,
...(typeof project.followers === 'number' ? { followers: project.followers } : {}),
...(project.license?.name || project.license?.id
? { license: project.license.name || project.license.id }
: {}),
categories: Array.isArray(project.categories) ? project.categories : [],
...(project.icon_url ? { iconUrl: project.icon_url } : {}),
links: {
project: `https://modrinth.com/project/${project.slug ?? projectId}`,
...(project.source_url ? { source: project.source_url } : {}),
...(project.issues_url ? { issues: project.issues_url } : {}),
...(project.wiki_url ? { wiki: project.wiki_url } : {}),
...(project.discord_url ? { discord: project.discord_url } : {})
},
...(mcVersion ? { mcVersion } : {}),
loaders,
...(compatible ? { compatible: summariseVersion(compatible) } : {}),
...(latestForLoader ? { latestForLoader: summariseVersion(latestForLoader) } : {}),
versionCount: versions.length
}
}

/**
* Install a project. `versionId` (from the detail view) is validated against
* that project's own version list before use, so the renderer can pick a
* version but never point the download at an arbitrary file.
*
* The target folder comes from the chosen version's loaders, not the server
* type: a hybrid (mohist/arclight) runs Bukkit plugins AND Forge mods, and
* deciding by type alone dropped every plugin into `mods/`.
*/
export async function installModrinth(
id: string,
projectId: string,
versionId?: string
): Promise<string> {
const server = getServer(id)
if (!server) throw new Error('server-not-found')
const versions = await httpJson<MrVersionInfo[]>(
`${MR}/project/${encodeURIComponent(projectId)}/version`
)
const v = versionId
? versions.find((x) => x.id === versionId)
: pickCompatibleVersion(versions, {
mcVersion:
server.mcVersion && server.mcVersion !== 'unknown' ? server.mcVersion : undefined,
loaders: searchLoaders(server.type)
})
if (!v) throw new Error('no-compatible-version')
const file = v.files.find((f) => f.primary) ?? v.files[0]
if (!file?.url) throw new Error('no-file-in-version')
const folder = folderForLoaders(v.loaders, fallbackFolder(server.type))
const dir = join(server.path, folder)
mkdirSync(dir, { recursive: true })
await downloadFile(file.url, join(dir, file.filename), { sha1: file.hashes?.sha1 })
log.info(`Mod installed: ${file.filename} (${v.version_number}) -> ${folder}/ for ${id}`)
return file.filename
}
5 changes: 4 additions & 1 deletion src/main/ipc/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,10 @@ export function registerIpc(): void {
return res.filePaths[0].split(/[\\/]/).pop() ?? null
})
H(IPC.modSearch, (_e, id: string, query: string) => mods.searchModrinth(id, query))
H(IPC.modInstall, (_e, id: string, projectId: string) => mods.installModrinth(id, projectId))
H(IPC.modDetail, (_e, id: string, projectId: string) => mods.modrinthDetail(id, projectId))
H(IPC.modInstall, (_e, id: string, projectId: string, versionId?: string) =>
mods.installModrinth(id, projectId, versionId)
)
H(IPC.modCheckUpdates, (_e, id: string) => mods.checkUpdates(id))
H(IPC.modApplyUpdate, (_e, id: string, path: string, versionId: string) =>
mods.applyUpdate(id, path, versionId)
Expand Down
80 changes: 78 additions & 2 deletions src/main/smoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ import {
isZipPackage,
type AdoptiumAsset
} from '@shared/javaProvision'
import { diffUpdates } from '@shared/mods'
import type { MrVersion } from '@shared/mods'
import { diffUpdates, folderForLoaders, pickCompatibleVersion } from '@shared/mods'
import type { MrVersion, MrVersionInfo } from '@shared/mods'
import { computeUptime, clipSessions } from '@shared/uptime'
import { evaluateRule, normalizeRule, IDLE, type AlertRule, type AlertSample } from '@shared/alerts'
import { analyze, type Finding } from '@shared/analysis'
Expand Down Expand Up @@ -595,6 +595,82 @@ export async function runModUpdateSmoke(): Promise<void> {
if (sl('vanilla').length !== 0) return fail('vanilla browse should not filter by loader')
console.log('MODUPDATE-SMOKE: browse-search loaders OK (plugin family, modded single, hybrid unions both)')

// Compatibility pick (#47 detail view). Same doctrine as the diff: recency
// comes from date_published, NEVER from the version string, and a stable
// release outranks a newer pre-release.
const V = (
id: string,
num: string,
loaders: string[],
games: string[],
type: string,
date: string
): MrVersionInfo => ({
id,
project_id: 'p',
version_number: num,
version_type: type,
loaders,
game_versions: games,
date_published: date,
files: [{ primary: true, filename: `${id}.jar`, hashes: { sha1: id } }]
})
const pool: MrVersionInfo[] = [
V('a', 'v9.9.9', ['fabric'], ['1.20.1'], 'release', '2026-01-01T00:00:00Z'),
V('b', 'v1.0.0', ['paper', 'spigot'], ['1.20.1'], 'release', '2025-06-01T00:00:00Z'),
V('c', 'v2.0.0', ['paper'], ['1.20.1'], 'beta', '2025-12-01T00:00:00Z'),
V('d', 'v3.0.0', ['paper'], ['1.21.4'], 'release', '2026-02-01T00:00:00Z')
]

// A Paper 1.20.1 server must not get the Fabric build even though it is the
// newest of all, and must prefer the stable release over the newer beta.
const paperPick = pickCompatibleVersion(pool, {
mcVersion: '1.20.1',
loaders: modsMod.searchLoaders('paper')
})
if (paperPick?.id !== 'b') {
return fail('paper 1.20.1 should pick the stable paper/spigot build, got ' + paperPick?.id)
}
// Nothing for this MC version -> undefined, but the loader still has builds.
const noMc = pickCompatibleVersion(pool, {
mcVersion: '1.7.10',
loaders: modsMod.searchLoaders('paper')
})
if (noMc) return fail('an unsupported MC version must not report a compatible build')
const anyMc = pickCompatibleVersion(pool, { loaders: modsMod.searchLoaders('paper') })
if (anyMc?.id !== 'd') return fail('latest-for-loader should be the newest paper release')
// A loader with no builds at all -> undefined (not a silent wrong pick).
if (pickCompatibleVersion(pool, { loaders: ['neoforge'] })) {
return fail('a loader with no builds must not report a compatible build')
}
// No loader filter (unknown server type) = do not exclude anything.
if (!pickCompatibleVersion(pool, { loaders: [] })) {
return fail('an unfiltered pick should still find something')
}
// Version STRINGS must not decide: 'v1.0.0' beat 'v9.9.9' above purely on
// loader match, and here a lexically tiny number wins on date.
const dateWins = pickCompatibleVersion(
[
V('old', 'v10.0.0', ['paper'], ['1.20.1'], 'release', '2024-01-01T00:00:00Z'),
V('new', 'v2.0.0', ['paper'], ['1.20.1'], 'release', '2026-01-01T00:00:00Z')
],
{ mcVersion: '1.20.1', loaders: ['paper'] }
)
if (dateWins?.id !== 'new') return fail('recency must come from the date, not the version text')
console.log('MODUPDATE-SMOKE: compatibility pick OK (loader + MC filter, release > newer beta, date not version text)')

// Hybrid install folder: mohist runs Bukkit plugins AND Forge mods, so the
// version's own loaders decide the folder - the server type cannot.
if (folderForLoaders(['forge'], 'plugins') !== 'mods') return fail('a forge build must go to mods/')
if (folderForLoaders(['paper', 'spigot'], 'mods') !== 'plugins') {
return fail('a bukkit-family build must go to plugins/')
}
if (folderForLoaders([], 'mods') !== 'mods') return fail('no loaders should use the fallback')
if (folderForLoaders(undefined, 'plugins') !== 'plugins') {
return fail('missing loaders should use the fallback')
}
console.log('MODUPDATE-SMOKE: install folder decided by version loaders (hybrid-safe)')

console.log('MODUPDATE-SMOKE: PASS')
app.exit(0)
} catch (e) {
Expand Down
4 changes: 3 additions & 1 deletion src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ const api: MsmsApi = {
deleteMod: (id, rel) => ipcRenderer.invoke(IPC.modDelete, id, rel),
addMod: (id, folder) => ipcRenderer.invoke(IPC.modAdd, id, folder),
searchMods: (id, query) => ipcRenderer.invoke(IPC.modSearch, id, query),
installMod: (id, projectId) => ipcRenderer.invoke(IPC.modInstall, id, projectId),
modDetail: (id, projectId) => ipcRenderer.invoke(IPC.modDetail, id, projectId),
installMod: (id, projectId, versionId) =>
ipcRenderer.invoke(IPC.modInstall, id, projectId, versionId),
checkModUpdates: (id) => ipcRenderer.invoke(IPC.modCheckUpdates, id),
applyModUpdate: (id, path, versionId) => ipcRenderer.invoke(IPC.modApplyUpdate, id, path, versionId),

Expand Down
16 changes: 15 additions & 1 deletion src/renderer/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,21 @@ export default {
updateTo: '→ {{version}}',
updateAvailable: 'update available',
upToDate: 'Up to date',
updatedOk: 'Updated to {{name}}'
updatedOk: 'Updated to {{name}}',
details: 'Details',
detailLoading: 'Loading details…',
detailFailed: 'Could not load details from Modrinth.',
author: 'Author',
followers: 'followers',
license: 'License',
versionCount: '{{n}} version(s)',
source: 'Source',
issues: 'Issues',
wiki: 'Wiki',
compatibleWith: 'Compatible — {{version}} supports MC {{mc}}',
compatibleAny: 'Latest matching version: {{version}}',
noVersionForMc: 'No version for MC {{mc}}. Newest for your loader is {{version}} (MC {{versions}}).',
noVersionForLoader: 'No version for this server type ({{loaders}}).'
},
backups: {
title: 'Backups',
Expand Down
17 changes: 16 additions & 1 deletion src/renderer/src/locales/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,22 @@ const tr: typeof en = {
updateTo: '→ {{version}}',
updateAvailable: 'güncelleme var',
upToDate: 'Güncel',
updatedOk: '{{name}} sürümüne güncellendi'
updatedOk: '{{name}} sürümüne güncellendi',
details: 'Ayrıntılar',
detailLoading: 'Ayrıntılar yükleniyor…',
detailFailed: 'Modrinth ayrıntıları alınamadı.',
author: 'Geliştirici',
followers: 'takipçi',
license: 'Lisans',
versionCount: '{{n}} sürüm',
source: 'Kaynak kod',
issues: 'Hata takibi',
wiki: 'Wiki',
compatibleWith: 'Uyumlu — {{version}} sürümü MC {{mc}} destekliyor',
compatibleAny: 'Eşleşen en yeni sürüm: {{version}}',
noVersionForMc:
'MC {{mc}} için sürüm yok. Yükleyiciniz için en yenisi {{version}} (MC {{versions}}).',
noVersionForLoader: 'Bu sunucu türü için sürüm yok ({{loaders}}).'
},
backups: {
title: 'Yedekler',
Expand Down
Loading
Loading