feat(mods): enhance mod management with profile controls and dependency checks

- Added dedicated profile control in the MODS panel for easier profile management.
- Implemented dependency checking during mod installation and updates to ensure compatibility.
- Improved manifest parsing to support GitHub repository hints for dependencies.
- Enhanced UI interactions for saving and renaming profiles.
- Updated tests to cover new functionality and ensure stability.
This commit is contained in:
1jamie
2026-08-13 17:33:29 -05:00
parent 26e9e1d597
commit 500d8c2c07
7 changed files with 1218 additions and 85 deletions
+673 -52
View File
@@ -568,10 +568,10 @@ local function modStatusColor(status)
return Strings("Incompatible"), PAL.yellow
end
-- MODS panel scope row: which game the list is answering for. Drawn from
-- GameVersion.ORDER so a new game needs nothing here.
-- MODS panel scope row: which game the list is answering for, plus dedicated Profile control (cycle + gear).
local function buildModScopeRow(imp, x, y, w, m)
local GameVersion = require("src.core.GameVersion")
local LauncherMods = require("src.mods.LauncherMods")
local h = math.max(Kit.tapMin(), math.floor(26 * m.s))
local gap = math.floor(6 * m.s)
local label = Strings("Show for:")
@@ -584,16 +584,63 @@ local function buildModScopeRow(imp, x, y, w, m)
{ id = version, label = GameVersion.info(version).label }
end
end
if #options < 2 then return 0 end
for _, opt in ipairs(options) do
local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
if Kit.chip(cx, y, cw, h, opt.label, imp.modScope == opt.id, PAL.lineStrong,
"mod-scope-" .. tostring(opt.id or "all")) then
local want = opt.id
queueAction(imp, "mod-scope-" .. tostring(want or "all"),
function() imp:_setModScope(want) end)
-- Dedicated Profile control section (cycle button + gear icon button) on right side of Scope Bar
local profiles, activeProf = LauncherMods.getProfiles()
local isCompact = (w < math.floor(500 * m.s))
local nameText = tostring(activeProf or "Default")
local profLabel = isCompact and nameText or Strings("Profile: %s", nameText)
local profW = Kit.textWidth("micro", profLabel) + math.floor(20 * m.s)
local gearW = h
local gearX = x + w - gearW
local profX = gearX - profW - math.floor(4 * m.s)
-- Tapping main profile button cycles to the next profile (styles match iconButton)
Kit._audit("control", profX, y, profW, h, "mod-scope-profile")
local focused = Kit.focusable("mod-scope-profile", profX, y, profW, h)
local hot = focused or Kit.hover(profX, y, profW, h)
Theme.fillRounded(profX, y, profW, h, hot and PAL.ink or PAL.surface, 1)
Theme.strokeRounded(profX, y, profW, h, PAL.line,
hot and Theme.A.focus or Theme.A.hairline, 1)
Kit.textCenterBold("micro", profLabel, profX,
y + (h - Kit.textHeight("micro")) / 2, profW,
hot and PAL.inverse or PAL.heading)
if Kit.press(profX, y, profW, h) or Kit._activateId == "mod-scope-profile" then
local nextIdx = 1
for i, p in ipairs(profiles) do
if p.name == activeProf then
nextIdx = (i % #profiles) + 1
break
end
end
local nextProf = profiles[nextIdx] and profiles[nextIdx].name
if nextProf then
queueAction(imp, "mod-scope-profile", function()
LauncherMods.applyProfile(nextProf)
if imp._refreshMods then imp:_refreshMods() end
end)
end
end
-- Tapping gear button opens the Profile Manager modal
imp._gearIcon = imp._gearIcon or (love and love.graphics and love.graphics.newImage and love.graphics.newImage("assets/launcher/gear.png"))
iconButton(imp, "mod-profile-gear", gearX, y, gearW, imp._gearIcon, function()
imp._profilesPopup = true
end)
if #options >= 2 then
for _, opt in ipairs(options) do
local cw = Kit.textWidth("micro", opt.label) + math.floor(18 * m.s)
if cx + cw <= profX - gap then
if Kit.chip(cx, y, cw, h, opt.label, imp.modScope == opt.id, PAL.lineStrong,
"mod-scope-" .. tostring(opt.id or "all")) then
local want = opt.id
queueAction(imp, "mod-scope-" .. tostring(want or "all"),
function() imp:_setModScope(want) end)
end
cx = cx + cw + gap
end
end
cx = cx + cw + gap
end
return h + math.floor(8 * m.s)
end
@@ -1380,33 +1427,31 @@ end
local function drawCheck(x, y, size, color)
love.graphics.push("all")
love.graphics.setColor(color)
love.graphics.setLineWidth(math.max(2, size * 0.16))
love.graphics.setLineWidth(math.max(2.2, size * 0.17))
love.graphics.setLineJoin("bevel")
love.graphics.line(
x, y + size * 0.55,
x + size * 0.35, y + size * 0.85,
x + size * 0.95, y + size * 0.15)
x + size * 0.02, y + size * 0.52,
x + size * 0.38, y + size * 0.80,
x + size * 1.015, y + size * 0.18)
love.graphics.pop()
end
-- One compact coloured checkbox for each game. The cartridge colour carries
-- the game identity even when the row is narrow; the letter keeps an unchecked
-- box legible without relying on colour alone.
-- the game identity even when the row is narrow.
local function modGameCheckbox(x, y, size, checked, game, id)
local color = cartColor(game)
local focused = Kit.focusable(id, x, y, size, size)
local hot = focused or Kit.hover(x, y, size, size)
if love.graphics then
Theme.fillRounded(x, y, size, size, PAL.bg, 1)
if checked then
Theme.fillRounded(x, y, size, size, color, 1)
drawCheck(x, y, size, PAL.inverse)
Theme.strokeRounded(x, y, size, size, color,
hot and Theme.A.focus or 0.9, 1.5)
drawCheck(x, y, size, color)
else
Theme.fillRounded(x, y, size, size, PAL.bg, 1)
Kit.textCenterBold("micro", game:sub(1, 1):upper(), x,
y + (size - Kit.textHeight("micro")) / 2, size, color)
Theme.strokeRounded(x, y, size, size, color,
hot and Theme.A.focus or Theme.A.hairline, 1)
end
Theme.strokeRounded(x, y, size, size, color,
hot and Theme.A.focus or Theme.A.hover, 1)
end
return Kit.press(x, y, size, size) or Kit._activateId == id
end
@@ -1418,28 +1463,79 @@ local function buildModsPanel(imp, x, y, w, availH, m)
local gap = m.gap
local cy = y
-- header: just the action cluster, right-aligned. No "Mods" headline (the
-- active tab already says it) and no enabled count (the toggles show it).
local place = Layout.rightCluster(x, w, math.floor(6 * m.s))
-- header: progressive action cluster. Surfaces primary/frequent actions
-- (Import, Updates, Sort) directly on the bar across screen sizes, placing
-- bulk actions (Enable all / Disable all) into More... on compact viewports.
local bh = m.btnH
local importLabel = imp:_modsImportButtonLabel()
local iw2 = Kit.textWidth("small", importLabel) + math.floor(24 * m.s)
btn(imp, place(iw2), cy, iw2, bh, "mods-import", importLabel, {
kind = "accent", font = "small",
action = function() imp:chooseMod() end })
local importW = Kit.textWidth("small", importLabel) + math.floor(24 * m.s)
if #mods > 0 then
local dw = Kit.textWidth("small", Strings("Disable all")) + math.floor(20 * m.s)
btn(imp, place(dw), cy, dw, bh, "mods-disable-all", Strings("Disable all"), {
kind = "warn", font = "small",
action = function() imp:_setAllMods(false) end })
local ew = Kit.textWidth("small", Strings("Enable all")) + math.floor(20 * m.s)
btn(imp, place(ew), cy, ew, bh, "mods-enable-all", Strings("Enable all"), {
kind = "good", font = "small",
action = function() imp:_setAllMods(true) end })
local sw = Kit.textWidth("small", Strings("Sort")) + math.floor(24 * m.s)
btn(imp, place(sw), cy, sw, bh, "mods-sort", Strings("Sort"), {
font = "small",
action = function() imp._sortPopup = true end })
local disableW = Kit.textWidth("small", Strings("Disable all")) + math.floor(20 * m.s)
local enableW = Kit.textWidth("small", Strings("Enable all")) + math.floor(20 * m.s)
local checkFullW = Kit.textWidth("small", Strings("Check for updates")) + math.floor(20 * m.s)
local checkShortW = Kit.textWidth("small", Strings("Updates")) + math.floor(20 * m.s)
local sortW = Kit.textWidth("small", Strings("Sort")) + math.floor(24 * m.s)
local moreW = Kit.textWidth("small", Strings("More...")) + math.floor(20 * m.s)
local fullReq = importW + disableW + enableW + checkFullW + sortW + math.floor(30 * m.s)
local medReq = importW + checkShortW + sortW + moreW + math.floor(24 * m.s)
local place = Layout.rightCluster(x, w, math.floor(6 * m.s))
if fullReq <= w then
-- Tier 1 (Desktop / Wide): Show all 5 full-text buttons
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, {
kind = "accent", font = "small",
action = function() imp:chooseMod() end })
btn(imp, place(disableW), cy, disableW, bh, "mods-disable-all", Strings("Disable all"), {
kind = "warn", font = "small",
action = function() imp:_setAllMods(false) end })
btn(imp, place(enableW), cy, enableW, bh, "mods-enable-all", Strings("Enable all"), {
kind = "good", font = "small",
action = function() imp:_setAllMods(true) end })
btn(imp, place(checkFullW), cy, checkFullW, bh, "mods-check-updates", Strings("Check for updates"), {
font = "small",
action = function() imp:_syncModUpdateInfo(true) end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small",
action = function() imp._sortPopup = true end })
elseif medReq <= w then
-- Tier 2 (Medium / Compact): Surface Import, Updates, and Sort directly
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, {
kind = "accent", font = "small",
action = function() imp:chooseMod() end })
btn(imp, place(checkShortW), cy, checkShortW, bh, "mods-check-updates", Strings("Updates"), {
font = "small",
action = function() imp:_syncModUpdateInfo(true) end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small",
action = function() imp._sortPopup = true end })
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
font = "small",
action = function() imp._modHeaderActionsPopup = true end })
else
-- Tier 3 (Ultra-Compact Mobile): Surface Import, Sort + More...
local importShortLabel = Strings("Import")
local importShortW = Kit.textWidth("small", importShortLabel) + math.floor(20 * m.s)
local miniReq = importShortW + sortW + moreW + math.floor(18 * m.s)
local useImportW = (miniReq <= w) and importShortW or importW
btn(imp, place(useImportW), cy, useImportW, bh, "mods-import", (miniReq <= w) and importShortLabel or importLabel, {
kind = "accent", font = "small",
action = function() imp:chooseMod() end })
btn(imp, place(sortW), cy, sortW, bh, "mods-sort", Strings("Sort"), {
font = "small",
action = function() imp._sortPopup = true end })
btn(imp, place(moreW), cy, moreW, bh, "mods-more-actions", Strings("More..."), {
font = "small",
action = function() imp._modHeaderActionsPopup = true end })
end
else
local place = Layout.rightCluster(x, w, math.floor(6 * m.s))
btn(imp, place(importW), cy, importW, bh, "mods-import", importLabel, {
kind = "accent", font = "small",
action = function() imp:chooseMod() end })
end
cy = cy + bh + math.floor(8 * m.s)
@@ -1534,17 +1630,28 @@ local function buildModsPanel(imp, x, y, w, availH, m)
local mod = mods[i]
local ry = listTop + (i - first) * (rowH + gap) - scroll
local rowKey = "mod-row-" .. mod.id
-- The whole row is the control: it opens the per-mod actions popup
-- (update / versions / delete moved there). Only the enable toggle
-- stays inline, because flipping a mod on and off is the everyday act.
local isFullyDisabled = true
if mod.enabledByVersion then
for _, on in pairs(mod.enabledByVersion) do
if on then isFullyDisabled = false; break end
end
else
isFullyDisabled = not mod.enabled
end
local focused = Kit.focusable(rowKey, x, ry, w, rowH)
local hot = focused or Kit.hover(x, ry, w, rowH)
Kit.card(x, ry, w, rowH, hot)
if isFullyDisabled then
Theme.fillRounded(x, ry, w, rowH, PAL.bg, 0.8, Theme.cardRadius())
Theme.strokeRounded(x, ry, w, rowH, PAL.muted, hot and Theme.A.hover or 0.25, 1, Theme.cardRadius())
else
Kit.card(x, ry, w, rowH, hot)
end
local pad = math.floor(12 * m.s)
local px, inner = x + pad, w - 2 * pad
local ly = ry + math.floor(10 * m.s)
local togGap = math.floor(4 * m.s)
local togGap = math.floor(5 * m.s) + 1
local info = mod.github and mod.github ~= "" and imp:_modUpdateInfo(mod.id)
-- These answer separate games, not a single shared install flag. The
@@ -1582,7 +1689,8 @@ local function buildModsPanel(imp, x, y, w, availH, m)
and Kit.textWidth("micro", mod.targets) + math.floor(12 * m.s) or 0
local nameShown = Kit.ellipsize("button", mod.name,
textW - badgeW - gamesW - math.floor(12 * m.s))
Kit.text("button", nameShown, px, ly, PAL.heading)
local headingCol = isFullyDisabled and PAL.muted or PAL.heading
Kit.text("button", nameShown, px, ly, headingCol)
local tagX = px + Kit.textWidth("button", nameShown) + math.floor(8 * m.s)
Kit.tag(tagX, ly, badgeW, Kit.textHeight("button"), mod.badge,
mod.experimental and PAL.yellow or PAL.muted)
@@ -2164,6 +2272,219 @@ local function buildVersionsModal(imp, m)
action = function() imp._modVersions = nil end })
end
-- Modal for per-profile actions (Duplicate, Rename, Delete) for compact / mobile / RG device compatibility
local function buildSingleProfileActionsModal(imp, m)
local pName = imp._singleProfileActions and imp._singleProfileActions.name
if not pName then imp._singleProfileActions = nil return end
local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
local options = SaveData.loadOptions()
local profiles, active = LauncherMods.getProfiles(options)
local pad = math.floor(18 * m.s)
local w = math.min(math.floor(380 * m.s), m.w - 2 * m.pad)
local gap = math.floor(8 * m.s)
local canDelete = (#profiles > 1)
local armed = deleteArmed(imp, "profile", pName, nil)
local btns = {
{
label = Strings("Duplicate profile"),
kind = "accent",
action = function()
LauncherMods.duplicateProfile(pName, options)
imp._singleProfileActions = nil
end
},
{
label = Strings("Rename profile"),
font = "small",
action = function()
imp._singleProfileActions = nil
imp._profileRenamePrompt = { oldName = pName, text = pName }
imp:_armTextInput(pName)
end
},
}
if canDelete then
btns[#btns + 1] = {
label = DELETE_LABEL(armed),
kind = armed and "warn" or "danger",
keepArm = true,
action = function()
imp:pressDelete("profile", pName, nil, function()
LauncherMods.deleteProfile(pName, options)
imp._singleProfileActions = nil
end)
end
}
end
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ #btns * (m.btnH + gap) + m.btnH + pad
local px, py, pw = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Kit.ellipsize("button", pName, pw - 2 * pad), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
for i, b in ipairs(btns) do
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "profact-" .. i, b.label, {
kind = b.kind,
font = "small",
keepArm = b.keepArm,
action = function()
b.action()
if imp._refreshMods then imp:_refreshMods() end
end
})
cy = cy + m.btnH + gap
end
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "profact-close",
Strings("Close"), {
font = "small",
action = function() imp._singleProfileActions = nil end })
end
-- Modal for Mod Profiles (#593) - interactive profile manager (switch, edit, duplicate, delete)
local function buildProfilesModal(imp, m)
local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
local options = SaveData.loadOptions()
local profiles, active = LauncherMods.getProfiles(options)
local pad = math.floor(18 * m.s)
local w = math.min(math.floor(460 * m.s), m.w - 2 * m.pad)
local gap = math.floor(8 * m.s)
local rowH = math.max(Kit.tapMin(), math.floor(40 * m.s))
local n = #profiles
local maxVisible = 4
local listH = math.min(maxVisible, math.max(1, n)) * (rowH + gap) - gap
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ m.btnH + gap + listH + math.floor(12 * m.s) + m.btnH + pad
local px, py, pw = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Strings("Mod Profiles"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
-- New Profile button
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "prof-new-top",
Strings("+ Create New Profile"), {
kind = "accent", font = "small",
action = function()
imp._profileSavePrompt = { text = "PROFILE " .. tostring(#profiles + 1) }
imp:_armTextInput(imp._profileSavePrompt.text)
end,
})
cy = cy + m.btnH + gap
-- Scrollable Profile Rows
local scrollMax = math.max(0, n * (rowH + gap) - gap - listH)
local scroll = clamp(imp._profScrollOffset or 0, 0, scrollMax)
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and Kit.hit(px + pad, cy, pw - 2 * pad, listH) then
scroll = clamp(scroll - Kit.wheelY * math.floor(36 * m.s), 0, scrollMax)
Kit.wheelY = 0
end
imp._profScrollOffset = scroll
Kit.pushClip(px + pad, cy, pw - 2 * pad, listH)
for i, p in ipairs(profiles) do
local ry = cy + (i - 1) * (rowH + gap) - scroll
if ry + rowH >= cy and ry <= cy + listH then
local isCur = (p.name == active)
local rowKey = "prof-row-" .. i
Kit.card(px + pad, ry, pw - 2 * pad, rowH, isCur)
local rx = px + pad + math.floor(12 * m.s)
local editBtnW = math.floor(64 * m.s)
local swBtnW = isCur and 0 or math.floor(64 * m.s)
local rightClusterW = editBtnW + swBtnW + (isCur and 0 or math.floor(4 * m.s))
local nameW = math.max(math.floor(80 * m.s), pw - 2 * pad - 2 * math.floor(12 * m.s) - rightClusterW - math.floor(50 * m.s))
local nameText = Kit.ellipsize("small", p.name, nameW)
Kit.text("small", nameText, rx, ry + (rowH - Kit.textHeight("small")) / 2, isCur and PAL.heading or PAL.muted)
if isCur then
Kit.tag(rx + Kit.textWidth("small", nameText) + math.floor(6 * m.s),
ry + (rowH - Kit.textHeight("micro")) / 2,
Kit.textWidth("micro", Strings("Active")) + math.floor(8 * m.s),
Kit.textHeight("micro"), Strings("Active"), PAL.green)
end
-- Right side controls: [Switch] (if not active) + [Edit]
local place = Layout.rightCluster(px + pad, pw - 2 * pad, math.floor(4 * m.s))
-- Edit button (opens per-profile action sheet)
btn(imp, place(editBtnW), ry + math.floor(4 * m.s), editBtnW, rowH - math.floor(8 * m.s), "prof-ed-" .. i,
Strings("Edit"), {
font = "micro",
action = function()
imp._singleProfileActions = { name = p.name }
end,
})
-- Switch button (if not active)
if not isCur then
btn(imp, place(swBtnW), ry + math.floor(4 * m.s), swBtnW, rowH - math.floor(8 * m.s), "prof-sw-" .. i,
Strings("Switch"), {
kind = "good", font = "micro",
action = function()
LauncherMods.applyProfile(p.name, options)
if imp._refreshMods then imp:_refreshMods() end
end,
})
end
end
end
Kit.popClip()
cy = cy + listH + math.floor(12 * m.s)
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "prof-close",
Strings("Close"), {
font = "small",
action = function() imp._profilesPopup = nil end })
end
-- Modal for MODS tab header actions on mobile / compact displays
local function buildModHeaderActionsModal(imp, m)
local pad = math.floor(18 * m.s)
local w = math.floor(380 * m.s)
local gap = math.floor(8 * m.s)
local btns = {
{ label = Strings("Mod profiles..."), action = function() imp._profilesPopup = true end },
{ label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(true) end },
{ label = Strings("Enable all mods"), kind = "good", action = function() imp:_setAllMods(true) end },
{ label = Strings("Disable all mods"), kind = "warn", action = function() imp:_setAllMods(false) end },
{ label = Strings("Sort mods..."), action = function() imp._sortPopup = true end },
}
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
+ #btns * (m.btnH + gap) + m.btnH + pad
local px, py, pw = modalPanel(m, w, h)
local cy = py + pad
Kit.text("button", Strings("More Mod Actions"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(12 * m.s)
for i, b in ipairs(btns) do
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modheadact-" .. i, b.label, {
kind = b.kind or "ghost", font = "small",
action = function()
imp._modHeaderActionsPopup = nil
b.action()
end
})
cy = cy + m.btnH + gap
end
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modheadact-close",
Strings("Close"), { font = "small",
action = function() imp._modHeaderActionsPopup = nil end })
end
-- Sort chooser, shared by the MODS and FIND MODS tabs (they share the
-- persisted key, so one popup serves both).
local function buildSortModal(imp, m)
@@ -2299,11 +2620,13 @@ local function buildModActionsModal(imp, m)
end
if not mod then imp._modActions = nil return end
local hasGit = mod.github and mod.github ~= ""
local depSpecs = mod.dependencySpecs or (mod.manifest and mod.manifest.dependencySpecs)
local hasDeps = depSpecs and #depSpecs > 0
local info = hasGit and imp:_modUpdateInfo(mod.id)
local pad = math.floor(18 * m.s)
local w = math.floor(440 * m.s)
local gap = math.floor(8 * m.s)
local nBtns = (hasGit and 2 or 0) + 2
local nBtns = (hasGit and 2 or 0) + (hasDeps and 1 or 0) + 2
local h = pad + Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + math.floor(12 * m.s)
+ nBtns * (m.btnH + gap) - gap + pad
@@ -2339,6 +2662,20 @@ local function buildModActionsModal(imp, m)
action = function() imp:_modGithubAction(id, "versions") end })
cy = cy + m.btnH + gap
end
if hasDeps then
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-deps",
Strings("Check dependencies"), {
kind = "accent", font = "small",
action = function()
local LauncherMods = require("src.mods.LauncherMods")
local depCheck = LauncherMods.checkDependencies(mod.manifest or mod)
if depCheck then
imp._modDepResolver = depCheck
end
imp._modActions = nil
end })
cy = cy + m.btnH + gap
end
local armed = deleteArmed(imp, "mod", id, nil)
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modact-del",
DELETE_LABEL(armed), {
@@ -2655,6 +2992,238 @@ local function buildSettingsModal(imp, m)
Kit.pager(px + pad, cy, pw - 2 * pad, cur, n, perPage, "settings"))
end
local function buildDepResolverModal(imp, m)
local res = imp._modDepResolver
if not res then return end
local pad = math.floor(18 * m.s)
local w = math.floor(540 * m.s)
local chipH = math.max(Kit.tapMin(), math.floor(28 * m.s))
local rowH = math.floor(74 * m.s)
local gap = math.floor(8 * m.s)
local warnH = math.floor(38 * m.s)
local n = #(res.deps or {})
local anyUnsatisfied = false
for _, d in ipairs(res.deps or {}) do
if d.status ~= "satisfied" and d.status ~= "disabled" then anyUnsatisfied = true; break end
end
local totalContentH = n > 0 and (n * rowH + (n - 1) * gap) or 0
-- Calculate content height dynamically so modal auto-fits small lists snuggly
local headerH = Kit.textHeight("button") + math.floor(4 * m.s)
+ Kit.textHeight("small") + math.floor(10 * m.s)
local warnTotalH = warnH + math.floor(12 * m.s)
local listMaxH = math.floor(240 * m.s)
local itemsH = math.min(totalContentH > 0 and totalContentH or rowH, listMaxH)
local footerH = math.floor(10 * m.s) + m.btnH
local wantedH = pad + headerH + warnTotalH + itemsH + footerH + pad
local h = math.floor(math.min(m.H - 2 * m.pad, math.max(260 * m.s, wantedH)))
local px, py, pw, ph = modalPanel(m, w, h)
local cy = py + pad
-- Title
local titleText = Strings("Dependency Resolver: ") .. tostring(res.targetMod.name or res.targetMod.id)
Kit.text("button", Kit.ellipsize("button", titleText, pw - 2 * pad), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + math.floor(4 * m.s)
-- Subtitle / intro
local subText = Strings("This mod requires additional dependencies or has conflicts:")
Kit.text("small", subText, px + pad, cy, PAL.muted)
cy = cy + Kit.textHeight("small") + math.floor(10 * m.s)
-- Security Disclaimer Banner Callout Card
Theme.fillRounded(px + pad, cy, pw - 2 * pad, warnH, PAL.rowBg, 1, Theme.radius())
Theme.strokeRounded(px + pad, cy, pw - 2 * pad, warnH, PAL.yellow, Theme.A.hover, 1, Theme.radius())
local warnMsg = Strings("Caution: Only pull dependencies from sources you trust.\nVerify source repositories before fetching.")
Kit.text("micro", warnMsg, px + pad + math.floor(12 * m.s), cy + math.floor(5 * m.s), PAL.yellow)
cy = cy + warnH + math.floor(12 * m.s)
-- List area bounds
local listH = (py + ph - pad) - cy - m.btnH - math.floor(10 * m.s)
local scrollMax = math.max(0, totalContentH - listH)
-- Mouse wheel scroll handling matching upstream pattern
if scrollMax > 0 and (Kit.wheelY or 0) ~= 0 and Kit.hit(px + pad, cy, pw - 2 * pad, listH) then
imp._depScrollOffset = clamp((imp._depScrollOffset or 0) - Kit.wheelY * math.floor(48 * m.s), 0, scrollMax)
Kit.wheelY = 0
elseif scrollMax == 0 then
imp._depScrollOffset = 0
else
imp._depScrollOffset = clamp(imp._depScrollOffset or 0, 0, scrollMax)
end
-- Pump active in-flight pulls
if imp._pumpDepPulls then
imp:_pumpDepPulls()
end
-- Clipped vertical scroll container
Kit.pushClip(px + pad, cy, pw - 2 * pad, listH)
local startY = cy - (imp._depScrollOffset or 0)
for i = 1, n do
local dep = res.deps[i]
local ry = startY + (i - 1) * (rowH + gap)
-- Cull rows completely outside the list viewport rectangle
if ry + rowH >= cy and ry <= cy + listH then
-- Item Card Fill & Stroke (matching launcher card interiors & radius)
local hot = Kit.hover(px + pad, ry, pw - 2 * pad, rowH)
Theme.row(px + pad, ry, pw - 2 * pad, rowH, hot and "hover" or "normal")
local ix = px + pad + math.floor(12 * m.s)
local innerW = pw - 2 * pad - math.floor(24 * m.s)
-- Dep title & range
local depHeader = tostring(dep.name or dep.id)
if dep.range and dep.range ~= "" then
depHeader = depHeader .. " (" .. dep.range .. ")"
end
Kit.text("small", Kit.ellipsize("small", depHeader, innerW - math.floor(210 * m.s)),
ix, ry + math.floor(8 * m.s), PAL.heading)
-- Status Badge & Subtext
local statusText, statusCol
if dep.status == "satisfied" then
statusText = Strings("Installed & Compatible (v%s)", tostring(dep.installedVersion or "?"))
statusCol = PAL.green
elseif dep.status == "incompatible" then
statusText = Strings("Incompatible (installed v%s, needs %s)", tostring(dep.installedVersion or "?"), tostring(dep.range or ""))
statusCol = PAL.yellow
elseif dep.status == "conflict" then
statusText = Strings("Incompatible mod enabled (v%s)", tostring(dep.installedVersion or "?"))
statusCol = PAL.red
elseif dep.status == "disabled" then
statusText = Strings("Disabled (conflict resolved)")
statusCol = PAL.green
else
statusText = Strings("Missing")
statusCol = PAL.red
end
Kit.text("micro", statusText, ix, ry + math.floor(8 * m.s) + Kit.textHeight("small") + math.floor(2 * m.s), statusCol)
-- Repo source line or Conflict reason
local repoLine
if dep.status == "conflict" or dep.kind == "conflict" then
repoLine = Strings("Listed as incompatible with ") .. tostring(res.targetMod.name or res.targetMod.id)
elseif dep.github then
repoLine = Strings("Source: github.com/") .. dep.github
else
repoLine = Strings("Source: Unknown (no repo listed)")
end
Kit.text("micro", repoLine, ix, ry + math.floor(8 * m.s) + Kit.textHeight("small") + Kit.textHeight("micro") + math.floor(4 * m.s), PAL.muted)
-- Action buttons right cluster (vertically centered inside card)
local ly = ry + math.floor((rowH - chipH) / 2)
local place = Layout.rightCluster(ix, innerW, math.floor(8 * m.s))
local pState = imp._depPullState and imp._depPullState[dep.id]
if pState and pState.stage ~= "done" and pState.stage ~= "error" then
local label = Strings("Pulling...")
if pState.stage == "fetching" then label = Strings("Fetching...")
elseif pState.stage == "downloading" then
if pState.progress and pState.progress > 0 then
label = Strings("Downloading %d%%", math.floor(pState.progress * 100))
else
label = Strings("Downloading...")
end
elseif pState.stage == "installing" then label = Strings("Installing...")
end
Kit.chip(place(Kit.textWidth("small", label) + math.floor(16 * m.s)), ly,
Kit.textWidth("small", label) + math.floor(16 * m.s), chipH, label, true, PAL.yellow, "dep-pulling-" .. i)
elseif dep.status == "conflict" then
local btnLabel = Strings("Disable mod")
local bw = Kit.textWidth("small", btnLabel) + math.floor(20 * m.s)
btn(imp, place(bw), ly, bw, chipH, "dep-dis-" .. i, btnLabel, {
kind = "warn", font = "small",
action = function()
local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.setEnabled(dep.id, false, imp.modScope)
dep.status = "disabled"
if imp._refreshMods then imp:_refreshMods() end
end,
})
elseif dep.status == "disabled" then
local chipLabel = Strings("Disabled")
local cw = Kit.textWidth("small", chipLabel) + math.floor(16 * m.s)
Kit.chip(place(cw), ly, cw, chipH, chipLabel, true, PAL.green, "dep-dischip-" .. i)
else
-- Pull / Update button if github repo is known and not satisfied
if dep.github and dep.status ~= "satisfied" then
local btnLabel = dep.status == "incompatible" and Strings("Update") or Strings("Pull from GitHub")
local bw = Kit.textWidth("small", btnLabel) + math.floor(20 * m.s)
btn(imp, place(bw), ly, bw, chipH, "dep-pull-" .. i, btnLabel, {
kind = "accent", font = "small",
action = function()
if imp._startDepPull then
imp:_startDepPull(dep)
end
end,
})
end
-- Open Source link button if safeUrl is present
if dep.safeUrl then
local bw = Kit.textWidth("small", Strings("View Source")) + math.floor(20 * m.s)
btn(imp, place(bw), ly, bw, chipH, "dep-view-" .. i, Strings("View Source"), {
font = "small",
action = function()
if love and love.system and love.system.openURL then
love.system.openURL(dep.safeUrl)
end
end,
})
end
end
end
end
Kit.popClip()
-- Scrollbar indicator if scrollMax > 0
if scrollMax > 0 then
local barW = math.floor(4 * m.s)
local barX = px + pw - pad - barW
local thumbH = math.max(math.floor(20 * m.s), math.floor(listH * (listH / totalContentH)))
local thumbY = cy + (listH - thumbH) * ((imp._depScrollOffset or 0) / scrollMax)
Theme.fill(barX, cy, barW, listH, PAL.bg, 0.4)
Theme.fill(barX, thumbY, barW, thumbH, PAL.muted, 0.7)
end
cy = cy + listH + math.floor(10 * m.s)
-- Bottom Action Buttons
if anyUnsatisfied then
local btnW = math.floor((pw - 2 * pad - math.floor(10 * m.s)) / 2)
btn(imp, px + pad, cy, btnW, m.btnH, "depresolver-pullall", Strings("Pull All Available"), {
kind = "accent", font = "small",
action = function()
for _, dep in ipairs(res.deps or {}) do
if dep.github and dep.status ~= "satisfied" and dep.status ~= "disabled" and imp._startDepPull then
imp:_startDepPull(dep)
end
end
end,
})
btn(imp, px + pad + btnW + math.floor(10 * m.s), cy, btnW, m.btnH, "depresolver-close", Strings("Done"), {
font = "small",
action = function()
imp._modDepResolver = nil
end,
})
else
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "depresolver-close", Strings("Done"), {
kind = "accent", font = "small",
action = function()
imp._modDepResolver = nil
end,
})
end
end
-- Whether ANY modal will draw this frame. draw() consults this BEFORE the
-- panels build: immediate mode hit-tests each control as it draws, so the
-- panels underneath a modal must run with Kit.blockClicks already raised or
@@ -2663,12 +3232,60 @@ end
local function modalUp(imp)
return (imp._settingsText or imp._settings or imp._rename
or imp._indexPrompt or imp._modConfirm or imp._modReleaseNotes
or imp._findDetails or imp._modVersions or imp._sortPopup
or imp._findDetails or imp._modVersions or imp._modDepResolver or imp._sortPopup
or imp._filterPopup or imp._indexManage or imp._modActions
or imp._findEntry or imp._gameManage) ~= nil
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
end
local function buildModals(imp, m)
if imp._profileRenamePrompt then
buildPrompt(imp, m, {
key = "profren", title = Strings("Rename profile"),
hint = Strings("Enter a new name for this profile:"),
text = imp._profileRenamePrompt.text or "", okLabel = Strings("Save"),
commit = function()
local txt = imp._profileRenamePrompt and imp._profileRenamePrompt.text
local old = imp._profileRenamePrompt and imp._profileRenamePrompt.oldName
if txt and txt ~= "" and old then
local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.renameProfile(old, txt)
imp._profileRenamePrompt = nil
imp:_disarmTextInput()
if imp._refreshMods then imp:_refreshMods() end
end
end,
cancel = function()
imp._profileRenamePrompt = nil
imp:_disarmTextInput()
end,
footnote = Strings("Enter to save - Esc to cancel"),
})
return true
end
if imp._profileSavePrompt then
buildPrompt(imp, m, {
key = "profsave", title = Strings("Save mod profile"),
hint = Strings("Enter a name for this mod profile:"),
text = imp._profileSavePrompt.text or "", okLabel = Strings("Save"),
commit = function()
local txt = imp._profileSavePrompt and imp._profileSavePrompt.text
if txt and txt ~= "" then
local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.saveProfile(txt)
imp._profileSavePrompt = nil
imp:_disarmTextInput()
if imp._refreshMods then imp:_refreshMods() end
end
end,
cancel = function()
imp._profileSavePrompt = nil
imp:_disarmTextInput()
end,
footnote = Strings("Enter to save - Esc to cancel"),
})
return true
end
if imp._settingsText then
local st = imp._settingsText
buildPrompt(imp, m, {
@@ -2733,10 +3350,14 @@ local function buildModals(imp, m)
return true
end
if imp._modVersions then buildVersionsModal(imp, m) return true end
if imp._modDepResolver then buildDepResolverModal(imp, m) return true end
-- The lighter popups come after the deep ones on purpose: opening
-- Versions or Details from inside an actions popup draws the deeper modal
-- while the popup's own state stays set, so closing the deep one drops
-- you back where you were.
if imp._singleProfileActions then buildSingleProfileActionsModal(imp, m) return true end
if imp._profilesPopup then buildProfilesModal(imp, m) return true end
if imp._modHeaderActionsPopup then buildModHeaderActionsModal(imp, m) return true end
if imp._sortPopup then buildSortModal(imp, m) return true end
if imp._filterPopup then buildFilterModal(imp, m) return true end
if imp._indexManage then buildIndexesModal(imp, m) return true end
+127 -1
View File
@@ -1649,7 +1649,7 @@ end
function RomImporter:_installMod(source)
if self.workState == "working" then return end
self.tab = "mods"
local ok, installed, res = pcall(function()
local ok, installed, res, manifest = pcall(function()
local LauncherMods = require("src.mods.LauncherMods")
return LauncherMods.installZip(source)
end)
@@ -1661,6 +1661,17 @@ function RomImporter:_installMod(source)
if installed then
pcall(self._refreshMods, self)
self.modNotice = { ok = true, text = "Installed " .. tostring(res) }
local LauncherMods = require("src.mods.LauncherMods")
local checkTarget = manifest
if not checkTarget and type(res) == "string" then
checkTarget = { id = res }
end
if checkTarget then
local depCheck = LauncherMods.checkDependencies(checkTarget)
if depCheck and depCheck.hasIssues then
self._modDepResolver = depCheck
end
end
else
self.modNotice = { ok = false, text = tostring(res) }
end
@@ -2716,6 +2727,43 @@ function RomImporter:fileUrl(path)
end
function RomImporter:keypressed(key)
if self._profileSavePrompt then
if key == "backspace" then
self._profileSavePrompt.text = utf8Back(self._profileSavePrompt.text or "")
elseif key == "return" or key == "kpenter" then
local txt = self._profileSavePrompt and self._profileSavePrompt.text
if txt and txt ~= "" then
local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.saveProfile(txt)
self._profileSavePrompt = nil
self:_disarmTextInput()
if self._refreshMods then self:_refreshMods() end
end
elseif key == "escape" then
self._profileSavePrompt = nil
self:_disarmTextInput()
end
return
end
if self._profileRenamePrompt then
if key == "backspace" then
self._profileRenamePrompt.text = utf8Back(self._profileRenamePrompt.text or "")
elseif key == "return" or key == "kpenter" then
local txt = self._profileRenamePrompt and self._profileRenamePrompt.text
local old = self._profileRenamePrompt and self._profileRenamePrompt.oldName
if txt and txt ~= "" and old then
local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.renameProfile(old, txt)
self._profileRenamePrompt = nil
self:_disarmTextInput()
if self._refreshMods then self:_refreshMods() end
end
elseif key == "escape" then
self._profileRenamePrompt = nil
self:_disarmTextInput()
end
return
end
if self._settingsText then
if key == "backspace" then
self._settingsText.text = utf8Back(self._settingsText.text)
@@ -2885,6 +2933,14 @@ function RomImporter:_commitRename()
end
function RomImporter:textinput(text)
if self._profileSavePrompt then
self._profileSavePrompt.text = utf8Cap((self._profileSavePrompt.text or "") .. text, MAX_SLOT_LABEL)
return
end
if self._profileRenamePrompt then
self._profileRenamePrompt.text = utf8Cap((self._profileRenamePrompt.text or "") .. text, MAX_SLOT_LABEL)
return
end
if self._settingsText then
local st = self._settingsText
st.text = utf8Cap(st.text .. text, st.maxLen or MAX_SLOT_LABEL)
@@ -3371,6 +3427,76 @@ function RomImporter:_pumpModInstall()
else
self.modNotice = { ok = true, text = text }
end
local LauncherMods = require("src.mods.LauncherMods")
local depCheck = LauncherMods.checkDependencies({ id = spec.modId })
if depCheck and depCheck.hasIssues then
self._modDepResolver = depCheck
end
end
-- Start an async pull for a single dependency
function RomImporter:_startDepPull(dep)
if not dep or not dep.github then return end
self._depPullState = self._depPullState or {}
local hFetch = require("src.mods.ModUpdate").beginFetchReleases(dep.github, dep.id, { force = true })
self._depPullState[dep.id] = {
dep = dep,
stage = "fetching",
fetchHandle = hFetch,
}
end
-- Pump all in-flight dependency pulls
function RomImporter:_pumpDepPulls()
if not self._depPullState then return end
local ModUpdate = require("src.mods.ModUpdate")
local LauncherMods = require("src.mods.LauncherMods")
for depId, state in pairs(self._depPullState) do
if state.stage == "fetching" then
local done, releases, err = ModUpdate.pumpFetchReleases(state.fetchHandle)
if done then
if err or not releases or #releases == 0 then
state.stage = "error"
state.err = err or "No downloadable releases found on GitHub"
else
local rel = releases[1]
if not rel or not rel.zip or not rel.zip.url then
state.stage = "error"
state.err = "Latest release has no downloadable .zip asset"
else
local tmpName = ("dep_%s_%s.zip"):format(depId, tostring(rel.version or os.time()))
state.dlHandle = ModUpdate.beginDownloadZip(rel.zip.url, tmpName, rel.zip.size)
state.stage = "downloading"
state.targetVersion = rel.version
end
end
end
elseif state.stage == "downloading" then
local done, localPath, err, progress = ModUpdate.pumpDownloadZip(state.dlHandle)
state.progress = progress
if done then
if err or not localPath then
state.stage = "error"
state.err = err or "Download failed"
else
state.stage = "installing"
local okInst, versionRes = LauncherMods.installDownloadedZip(depId, localPath, state.targetVersion)
if okInst then
state.stage = "done"
pcall(self._refreshMods, self)
if self._modDepResolver and self._modDepResolver.targetMod then
local updated = LauncherMods.checkDependencies(self._modDepResolver.targetMod)
self._modDepResolver = updated
end
else
state.stage = "error"
state.err = tostring(versionRes or "Installation failed")
end
end
end
end
end
end
function RomImporter:_confirmModUpdate(modId, release)
+315 -4
View File
@@ -41,6 +41,8 @@ local CacheFs = require("src.import.CacheFs")
local LauncherMods = {}
local discover -- forward declaration for helper functions above line 343
-- ------- pure status derivation
-- A hard-dependency / conflict / version verdict for one manifest. mods is
@@ -105,6 +107,166 @@ local function statusFor(mods, id, enabledSet, enabled, version, forcedFor)
return "ok", "Ready"
end
-- Resolves the best-known GitHub owner/repo string for a dependency spec, if any.
function LauncherMods.resolveDependencyRepo(depId, parentManifest, installedDep)
if not depId or depId == "" then return nil end
-- 1. Check spec hint if parentManifest dependencySpecs carries it
if parentManifest and parentManifest.dependencySpecs then
for _, spec in ipairs(parentManifest.dependencySpecs) do
if spec.id == depId and spec.github then
return spec.github
end
end
end
-- 2. Check parentManifest raw dependency_sources
if parentManifest and parentManifest.raw and type(parentManifest.raw.dependency_sources) == "table" then
local src = parentManifest.raw.dependency_sources[depId]
if src then
local ok, clean = pcall(Manifest.parseGithub, src)
if ok and clean then return clean end
end
end
-- 3. Check installed dependency manifest
if installedDep and installedDep.github then
return installedDep.github
end
-- 4. Check ModIndex entries if available
local okModIndex, ModIndex = pcall(require, "src.mods.ModIndex")
if okModIndex and ModIndex and type(ModIndex.sources) == "function" then
for _, src in ipairs(ModIndex.sources() or {}) do
local cached = ModIndex.readCache and ModIndex.readCache(src.feed)
if cached and type(cached.index) == "table" then
for _, entry in ipairs(cached.index) do
if type(entry) == "table" and entry.id == depId and entry.github then
local ok, clean = pcall(Manifest.parseGithub, entry.github)
if ok and clean then return clean end
end
end
end
end
end
return nil
end
-- Inspect manifest dependencies and conflicts against installed mods.
-- Returns: { hasIssues = bool, targetMod = {...}, deps = [ { id, name, range, status, kind, installedVersion, github, safeUrl }, ... ] }
function LauncherMods.checkDependencies(manifest, options, version, installedManifests)
if not manifest then
return { hasIssues = false, targetMod = { name = "Unknown" }, deps = {} }
end
local SaveData = require("src.core.SaveData")
local manifests = installedManifests or discover()
local installedMap = {}
for _, m in ipairs(manifests) do
installedMap[m.id] = m
end
local depsResult = {}
local hasIssues = false
-- 1. Hard Dependencies (dependencySpecs)
if type(manifest.dependencySpecs) == "table" then
for _, spec in ipairs(manifest.dependencySpecs) do
local depId = spec.id
local range = spec.range
local installedDep = installedMap[depId]
local status = "satisfied"
local installedVersion = installedDep and installedDep.version or nil
if not installedDep then
status = "missing"
hasIssues = true
elseif range and not Semver.satisfies(installedDep.version, range) then
status = "incompatible"
hasIssues = true
end
local ghRepo = LauncherMods.resolveDependencyRepo(depId, manifest, installedDep)
local safeUrl = ghRepo and ("https://github.com/" .. ghRepo) or nil
depsResult[#depsResult + 1] = {
id = depId,
name = (installedDep and installedDep.name) or depId,
range = range,
status = status,
kind = "dependency",
installedVersion = installedVersion,
github = ghRepo,
safeUrl = safeUrl,
}
end
end
-- 2. Conflicts / Incompatible Mods (conflictSpecs)
local conflictIdsSeen = {}
local scope = SaveData.modScope and SaveData.modScope(version) or version
local isEnabled = function(modId)
if not options then return true end
local dec = SaveData.modEnabled(options, modId, scope)
if dec ~= nil then return dec == true end
local m = installedMap[modId]
return m and not m.experimental
end
-- (a) Conflicts declared by target manifest
if type(manifest.conflictSpecs) == "table" then
for _, spec in ipairs(manifest.conflictSpecs) do
local conflictId = spec.id
local installedOther = installedMap[conflictId]
if installedOther and isEnabled(conflictId) and not conflictIdsSeen[conflictId] then
conflictIdsSeen[conflictId] = true
hasIssues = true
depsResult[#depsResult + 1] = {
id = conflictId,
name = installedOther.name or conflictId,
status = "conflict",
kind = "conflict",
installedVersion = installedOther.version or "?",
github = nil,
safeUrl = nil,
}
end
end
end
-- (b) Reverse conflicts declared by installed mods against target manifest
if manifest.id then
for _, other in ipairs(manifests) do
if other.id ~= manifest.id and isEnabled(other.id) and not conflictIdsSeen[other.id] then
local conflicts = other.conflictSpecs or {}
for _, spec in ipairs(conflicts) do
if spec.id == manifest.id then
conflictIdsSeen[other.id] = true
hasIssues = true
depsResult[#depsResult + 1] = {
id = other.id,
name = other.name or other.id,
status = "conflict",
kind = "conflict",
installedVersion = other.version or "?",
github = nil,
safeUrl = nil,
}
break
end
end
end
end
end
return {
hasIssues = hasIssues,
targetMod = {
id = manifest.id,
name = manifest.name or manifest.id,
version = manifest.version or "?",
},
deps = depsResult,
}
end
-- deriveList(manifests, options [, version]) -> the panel row list, pure.
-- manifests is an array of validated manifests (Manifest.validate output);
-- options is the options table (options.mods, options.modsByVersion and
@@ -165,6 +327,8 @@ function LauncherMods.deriveList(manifests, options, version)
statusDetail = detail,
github = m.github,
experimental = m.experimental == true,
dependencySpecs = m.dependencySpecs,
manifest = m,
-- what game this mod is for, and whether it will run on the one the
-- panel is showing (src/mods/ModTargets.lua)
targets = ModTargets.chip(m),
@@ -252,7 +416,7 @@ end
-- Scan "mods/" one level deep for valid manifests (mirrors Loader:_discover,
-- but validates only -- no entry chunk is ever loaded). First id wins on a
-- duplicate. Returns an array of validated manifests.
local function discover()
discover = function()
local fs = love and love.filesystem
local out = {}
if not (fs and fs.getInfo and fs.getDirectoryItems) then return out end
@@ -386,6 +550,7 @@ function LauncherMods.setEnabled(id, enabled, version)
local options = SaveData.loadOptions()
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
SaveData.saveOptions(options)
LauncherMods.syncActiveProfile(options)
return true
end
@@ -409,6 +574,7 @@ function LauncherMods.setAllEnabled(ids, enabled, version)
end
end
SaveData.saveOptions(options)
LauncherMods.syncActiveProfile(options)
return true
end
@@ -674,9 +840,9 @@ function LauncherMods.adoptStrays() return scanStrays(true) end
-- opts.replace = true uninstalls an existing same-id mod first (updates /
-- rollbacks). opts.expectId, when set, refuses a zip whose manifest id differs.
function LauncherMods.installZip(source, opts)
local ok, result, err = pcall(LauncherMods._installZipInner, source, opts)
local ok, result, res2, res3 = pcall(LauncherMods._installZipInner, source, opts)
if not ok then return nil, "import failed: " .. tostring(result) end
return result, err
return result, res2, res3
end
function LauncherMods._installZipInner(source, opts)
@@ -786,7 +952,7 @@ function LauncherMods._installZipInner(source, opts)
return nil, copyErr or "could not copy the mod files"
end
cleanup()
return true, manifest.id
return true, manifest.id, manifest
end
-- Install (or replace) a mod from a GitHub release zip URL.
@@ -912,4 +1078,149 @@ function LauncherMods.uninstall(id)
return true
end
-- ----------------------------------------------------------- Mod Profiles (#593)
local ModProfile = require("src.mods.ModProfile")
function LauncherMods.getProfiles(options)
options = options or SaveData.loadOptions()
local manifests = discover()
ModProfile.ensureFirst(options, manifests, options.modOptions)
return options.modProfiles or {}, options.activeProfile or "PROFILE 1"
end
function LauncherMods.applyProfile(profileName, options)
options = options or SaveData.loadOptions()
local profiles = options.modProfiles or {}
local targetProfile
for _, p in ipairs(profiles) do
if p.name == profileName then targetProfile = p; break end
end
if not targetProfile then return false end
ModProfile.restoreVersions(targetProfile, options)
options.activeProfile = profileName
SaveData.saveOptions(options)
return true
end
function LauncherMods.saveProfile(profileName, options)
options = options or SaveData.loadOptions()
local manifests = discover()
options.modProfiles = options.modProfiles or {}
local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion)
snap.name = profileName
local existingIdx
for i, p in ipairs(options.modProfiles) do
if p.name == profileName then existingIdx = i; break end
end
if existingIdx then
options.modProfiles[existingIdx] = snap
else
options.modProfiles[#options.modProfiles + 1] = snap
end
options.activeProfile = profileName
SaveData.saveOptions(options)
return snap
end
local function copyTable(tbl)
if type(tbl) ~= "table" then return tbl end
local copy = {}
for k, v in pairs(tbl) do
copy[k] = type(v) == "table" and copyTable(v) or v
end
return copy
end
function LauncherMods.syncActiveProfile(options)
options = options or SaveData.loadOptions()
local activeName = options.activeProfile or "PROFILE 1"
local profiles = options.modProfiles or {}
local manifests = discover()
local snap = ModProfile.capture(manifests, options.modOptions, options.modsByVersion)
snap.name = activeName
local found = false
for i, p in ipairs(profiles) do
if p.name == activeName then
profiles[i] = snap
found = true
break
end
end
if not found then
profiles[#profiles + 1] = snap
end
options.modProfiles = profiles
options.activeProfile = activeName
SaveData.saveOptions(options)
return snap
end
function LauncherMods.duplicateProfile(sourceName, options)
options = options or SaveData.loadOptions()
local profiles = options.modProfiles or {}
local sourceProfile
for _, p in ipairs(profiles) do
if p.name == sourceName then sourceProfile = p; break end
end
if not sourceProfile then return nil end
local baseName = sourceName .. " (Copy)"
local newName = baseName
local n = 1
local taken = {}
for _, p in ipairs(profiles) do taken[p.name] = true end
while taken[newName] do
n = n + 1
newName = sourceName .. " (" .. n .. ")"
end
local snap = {
name = newName,
enabled = copyTable(sourceProfile.enabled),
options = copyTable(sourceProfile.options),
slots = copyTable(sourceProfile.slots),
enabledByVersion = copyTable(sourceProfile.enabledByVersion),
}
profiles[#profiles + 1] = snap
options.modProfiles = profiles
options.activeProfile = newName
SaveData.saveOptions(options)
return snap
end
function LauncherMods.renameProfile(oldName, newName, options)
options = options or SaveData.loadOptions()
if not newName or newName == "" then return false end
local profiles = options.modProfiles or {}
for i, p in ipairs(profiles) do
if p.name == oldName then
p.name = newName
if options.activeProfile == oldName then
options.activeProfile = newName
end
SaveData.saveOptions(options)
return true
end
end
return false
end
function LauncherMods.deleteProfile(profileName, options)
options = options or SaveData.loadOptions()
options.modProfiles = options.modProfiles or {}
local newProfiles = {}
for _, p in ipairs(options.modProfiles) do
if p.name ~= profileName then newProfiles[#newProfiles + 1] = p end
end
options.modProfiles = newProfiles
if options.activeProfile == profileName then
local fallback = newProfiles[1] and newProfiles[1].name or "PROFILE 1"
LauncherMods.applyProfile(fallback, options)
else
SaveData.saveOptions(options)
end
return true
end
return LauncherMods
+43 -22
View File
@@ -31,26 +31,6 @@ local function violation(strict, id, message)
Logger.warn("[%s] %s", tostring(id), message)
end
-- "id" or "id@<range>"; a malformed id or range fails for every api level
-- because there is no sane fallback reading for it
local function parseSpecs(list, field)
local specs = {}
for _, entry in ipairs(list) do
assert(type(entry) == "string" and entry ~= "",
field .. " entries must be non-empty strings")
local id, range = entry:match("^([%w_%-]+)@(.+)$")
if not id then
id = entry:match("^([%w_%-]+)$")
assert(id, ("malformed %s entry %q"):format(field, entry))
range = nil
end
local ok, err = Semver.validRange(range)
assert(ok, ("malformed %s range in %q: %s"):format(field, entry, tostring(err)))
specs[#specs + 1] = { id = id, range = range }
end
return specs
end
-- Optional GitHub repo for launcher auto-update / other-versions.
-- Accepts "owner/repo" or a github.com URL; empty/absent means no updates.
function Manifest.parseGithub(value)
@@ -73,6 +53,47 @@ function Manifest.parseGithub(value)
return owner .. "/" .. repo
end
-- "id", "id@<range>", "id@<range>#<github>", "id#<github>", or table entry
local function parseSpecs(list, field, sources)
local specs = {}
sources = type(sources) == "table" and sources or {}
for _, entry in ipairs(list) do
local id, range, ghHint
if type(entry) == "table" then
id = entry.id
range = entry.range or entry.version
ghHint = entry.github or entry.repo
elseif type(entry) == "string" and entry ~= "" then
local main, hashRepo = entry:match("^([^#]+)#(.*)$")
if main then
entry = main
ghHint = hashRepo
end
id, range = entry:match("^([%w_%-]+)@(.+)$")
if not id then
id = entry:match("^([%w_%-]+)$")
assert(id, ("malformed %s entry %q"):format(field, entry))
range = nil
end
else
error(field .. " entries must be non-empty strings or tables")
end
assert(id, ("malformed %s entry"):format(field))
local ok, err = Semver.validRange(range)
assert(ok, ("malformed %s range in %q: %s"):format(field, tostring(entry), tostring(err)))
local parsedGh = nil
local rawGh = ghHint or sources[id]
if rawGh then
local okGh, cleanGh = pcall(Manifest.parseGithub, rawGh)
if okGh and cleanGh then parsedGh = cleanGh end
end
specs[#specs + 1] = { id = id, range = range, github = parsedGh }
end
return specs
end
-- conflicts + incompatible (alias) merged, first-wins on duplicate ids
local function mergeConflictLists(conflicts, incompatible)
local seen, out = {}, {}
@@ -248,8 +269,8 @@ function Manifest.validate(raw, path)
optional_dependencies = array(raw.optional_dependencies),
conflicts = conflicts,
incompatible = array(raw.incompatible),
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies"),
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies"),
dependencySpecs = parseSpecs(array(raw.dependencies), "dependencies", raw.dependency_sources),
optionalSpecs = parseSpecs(array(raw.optional_dependencies), "optional_dependencies", raw.dependency_sources),
conflictSpecs = parseSpecs(conflicts, "conflicts"),
category = raw.category or "OTHER",
game_version = raw.game_version,
+9 -3
View File
@@ -334,9 +334,15 @@ function ModIndex.compatIssues(entry, ctx)
local function eachSpec(spec, fn)
if type(spec) ~= "table" then return end
for k, v in pairs(spec) do
if type(k) == "number" and type(v) == "string" then
local id, range = v:match("^([^@]+)@(.+)$")
fn(id or v, range)
if type(k) == "number" then
if type(v) == "table" and type(v.id) == "string" then
fn(v.id, v.range or v.version, v.github or v.repo)
elseif type(v) == "string" then
local main, hashRepo = v:match("^([^#]+)#(.*)$")
if main then v = main end
local id, range = v:match("^([^@]+)@(.+)$")
fn(id or v, range, hashRepo)
end
elseif type(k) == "string" then
fn(k, type(v) == "string" and v or nil)
end
+39 -3
View File
@@ -138,7 +138,25 @@ check(not pcall(Manifest.parseGithub, "not a repo"),
check(not pcall(Manifest.validate, {
id = "badgh", name = "Bad", version = "1.0.0", entry = "main.lua",
github = "ftp://example.com/x",
}), "a bad github field fails manifest validation")
}), "an unsupported github URL fails validation")
-- ------- dependency github repo spec hints & dependency resolver
local depGh = Manifest.validate({
id = "depgh", name = "DepGH", version = "1.0.0", entry = "main.lua",
dependencies = { "colorlib@^1.2.0#Acme/ColorLib", "soundpack#Acme/SoundPack" },
dependency_sources = { helper = "Acme/Helper" },
})
check(depGh.dependencySpecs[1].id == "colorlib" and depGh.dependencySpecs[1].github == "Acme/ColorLib",
"dependency spec hash hint parses github owner/repo")
check(depGh.dependencySpecs[2].id == "soundpack" and depGh.dependencySpecs[2].github == "Acme/SoundPack",
"dependency spec hash hint without range parses github owner/repo")
local LauncherMods = require("src.mods.LauncherMods")
local depCheck = LauncherMods.checkDependencies(depGh)
check(depCheck.hasIssues == true, "missing dependencies trigger issues verdict")
check(#depCheck.deps == 2, "dependency check lists all specs")
check(depCheck.deps[1].status == "missing", "absent dependency reports missing")
check(depCheck.deps[1].safeUrl == "https://github.com/Acme/ColorLib", "safeUrl built from validated github repo")
local v1 = Manifest.validate({
id = "v1", name = "V1", version = "1.0.0", entry = "main.lua",
@@ -519,8 +537,26 @@ local emptyLoader = Loader.new({ fs = memfs({}) })
check(emptyLoader:load(pristine) == true, "an empty mods dir still loads clean")
check(#emptyLoader:status().errors == 0, "no mods means no diagnostics")
check(#emptyLoader.order == 0, "no mods means an empty load order")
check(pristine.pokemon.A.hp == 1 and next(pristine.items) == nil,
"no-mod load leaves data untouched")
-- ------- dependency resolver conflict detection test
local LauncherMods = require("src.mods.LauncherMods")
local testTargetManifest = Manifest.validate({
id = "new_mod",
name = "New Mod",
version = "1.0.0",
entry = "main.lua",
incompatible = { "colorlib" },
}, "mods/new_mod")
local installedColorlib = Manifest.validate({
id = "colorlib",
name = "Color Lib",
version = "1.0.0",
entry = "main.lua",
}, "mods/colorlib")
local depConflictCheck = LauncherMods.checkDependencies(testTargetManifest, nil, nil, { installedColorlib })
check(depConflictCheck.hasIssues == true, "conflict with colorlib triggers hasIssues")
check(#depConflictCheck.deps == 1 and depConflictCheck.deps[1].status == "conflict",
"incompatible mod is flagged as status conflict")
check(depConflictCheck.deps[1].kind == "conflict", "conflict item carries kind conflict")
Runtime.install(savedEvents, savedHooks)
+12
View File
@@ -1162,6 +1162,18 @@ seedOpts.modProfiles = {}
ModProfile.ensureFirst(seedOpts, ms.status.available, {})
check(#seedOpts.modProfiles == 0, "seeding never runs twice")
local LauncherMods = require("src.mods.LauncherMods")
local testProfOpts = { activeProfile = "P1", modProfiles = { { name = "P1", enabled = { a = true } } } }
local dupSnap = LauncherMods.duplicateProfile("P1", testProfOpts)
check(dupSnap and dupSnap.name == "P1 (Copy)" and testProfOpts.activeProfile == "P1 (Copy)",
"duplicateProfile creates P1 (Copy) and activates it")
check(LauncherMods.renameProfile("P1 (Copy)", "RenamedP", testProfOpts) == true,
"renameProfile renames active profile")
check(testProfOpts.activeProfile == "RenamedP", "activeProfile updates on rename")
check(LauncherMods.deleteProfile("RenamedP", testProfOpts) == true, "deleteProfile removes profile")
check(#testProfOpts.modProfiles == 1 and testProfOpts.modProfiles[1].name == "P1", "only original profile remains")
check(testProfOpts.activeProfile == "P1", "activeProfile falls back to remaining profile")
-- permissions rows
local permy = manifest("permy", { permissions = { "network" } })
local msP = ManagerState.new(managerGame(fakeLoader({ permy })))