Merge pull request #1544 from castdrian/safe-mode-report-issue

feat(launcher): add safe mode and issue reporting
This commit is contained in:
bryanthaboi
2026-08-19 06:00:00 -04:00
committed by GitHub
10 changed files with 561 additions and 27 deletions
+219
View File
@@ -0,0 +1,219 @@
local SaveData = require("src.core.SaveData")
local Version = require("src.core.Version")
local IssueReport = {}
local FORM_URL = "https://github.com/bryanthaboi/gen1recomp/issues/new"
local TEMPLATE = "bug_report.yml"
local function clean(value)
if value == nil then return nil end
local text = tostring(value):gsub("^%s+", ""):gsub("%s+$", "")
if text == "" or text == "unknown" or text == "Unknown" then return nil end
return text
end
local function call(fn, ...)
if type(fn) ~= "function" then return nil end
local ok, a, b, c, d, e = pcall(fn, ...)
if not ok then return nil end
return a, b, c, d, e
end
local function invoke(fn, ...)
if type(fn) ~= "function" then return false end
local ok, result = pcall(fn, ...)
return ok, result
end
local function commandValue(command)
if not io or type(io.popen) ~= "function" then return nil end
local ok, pipe = pcall(io.popen, command, "r")
if not ok or not pipe then return nil end
local readOK, value = pcall(pipe.read, pipe, "*l")
pcall(pipe.close, pipe)
if not readOK then return nil end
return clean(value)
end
local function percentEncode(value)
local text = tostring(value or "")
return (text:gsub("([^%w%-_%.~])", function(char)
return ("%%%02X"):format(char:byte())
end))
end
local function formOS(raw)
local values = {
["OS X"] = "macOS",
macOS = "macOS",
Windows = "Windows",
Linux = "Linux",
Android = "Android",
iOS = "iOS",
NX = "Nintendo Switch",
UWP = "Xbox",
Xbox = "Xbox",
}
return values[raw] or ""
end
local function loveVersion()
local major, minor, revision, codename = call(love and love.getVersion)
if not major then return "" end
local result = tostring(major) .. "." .. tostring(minor) .. "." .. tostring(revision)
if codename and codename ~= "" then result = result .. " (" .. tostring(codename) .. ")" end
return result
end
local function appVersion()
local version = clean(Version.engine)
if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end
return version
end
local function deviceModel(rawOS, system)
local model = clean(call(system.getModel))
if model then return model end
if rawOS == "OS X" or rawOS == "macOS" then
return commandValue("sysctl -n hw.model 2>/dev/null")
end
if rawOS == "Windows" then
return commandValue("powershell.exe -NoProfile -NonInteractive -Command \"(Get-CimInstance Win32_ComputerSystem).Model\" 2>NUL")
end
if rawOS == "Linux" then
return commandValue("cat /sys/devices/virtual/dmi/id/product_name 2>/dev/null")
or commandValue("cat /sys/devices/virtual/dmi/id/model 2>/dev/null")
end
if rawOS == "Android" then
return commandValue("getprop ro.product.model 2>/dev/null")
end
return nil
end
local function modRows(context)
if context and type(context.mods) == "table" then return context.mods end
local ok, LauncherMods = pcall(require, "src.mods.LauncherMods")
if ok and LauncherMods and LauncherMods.list then
local listed = call(LauncherMods.list)
if type(listed) == "table" then return listed end
end
return {}
end
local function modNames(rows, safeMode)
local enabled = {}
for _, mod in ipairs(rows or {}) do
if type(mod) == "table" then
local name = clean(mod.name or mod.id)
if name and not safeMode and mod.enabled == true then
enabled[#enabled + 1] = name
end
end
end
table.sort(enabled)
return enabled
end
local function metadata(options, context)
local system = love and love.system or {}
local graphics = love and love.graphics or {}
local window = love and love.window or {}
local rawOS = clean(call(system.getOS))
local model = deviceModel(rawOS, system)
local renderer, rendererVersion, _, rendererDevice = call(graphics.getRendererInfo)
local width, height = call(graphics.getDimensions)
local pixelWidth, pixelHeight = call(graphics.getPixelDimensions)
local modeWidth, modeHeight, flags = call(window.getMode)
local safeMode = SaveData.isSafeMode(options)
local rows = modRows(context or {})
local enabledMods = modNames(rows, safeMode)
local lines = { "Diagnostics:" }
local function add(label, value)
value = clean(value)
if value then lines[#lines + 1] = "- " .. label .. ": " .. value end
end
add("Platform", formOS(rawOS))
local hardware = model
if rendererDevice and rendererDevice ~= model then
hardware = hardware and (hardware .. " (" .. rendererDevice .. ")") or rendererDevice
end
add("Device", hardware)
local rendererDetails = clean(renderer)
if rendererDetails and clean(rendererVersion) then
rendererDetails = rendererDetails .. " " .. clean(rendererVersion)
end
add("Renderer", rendererDetails)
local displayWidth, displayHeight = width or modeWidth or pixelWidth, height or modeHeight or pixelHeight
if displayWidth and displayHeight then
add("Display", tostring(displayWidth) .. "x" .. tostring(displayHeight))
end
if pixelWidth and pixelHeight
and (pixelWidth ~= displayWidth or pixelHeight ~= displayHeight) then
add("Pixel display", tostring(pixelWidth) .. "x" .. tostring(pixelHeight))
end
if flags and flags.fullscreen == true then add("Fullscreen", "yes") end
local version = appVersion()
add("App", version ~= "" and Version.title() or "gen1recomp")
add("LÖVE", loveVersion())
if safeMode then add("Safe mode", "on") end
return {
rawOS = rawOS,
os = formOS(rawOS),
device = model,
version = version,
safeMode = safeMode,
enabledMods = enabledMods,
metadata = table.concat(lines, "\n"),
}
end
function IssueReport.build(options, context)
options = options or SaveData.loadOptions()
context = context or {}
local info = metadata(options, context)
local fields = {
summary = "",
mods_which = #info.enabledMods > 0 and table.concat(info.enabledMods, ", ") or "",
version = info.version or "",
location = "",
screenshot = "",
steps = "",
expected = "",
extra = info.metadata,
}
local params = {
"template=" .. percentEncode(TEMPLATE),
"title=" .. percentEncode("bug: replace this with a meaningful title"),
}
local order = { "summary", "mods_which",
"version", "location", "screenshot", "steps", "expected", "extra" }
for _, key in ipairs(order) do
params[#params + 1] = key .. "=" .. percentEncode(fields[key])
end
return FORM_URL .. "?" .. table.concat(params, "&"), fields, info
end
function IssueReport.open(options, context)
local url = IssueReport.build(options, context)
local system = love and love.system or {}
local opened, openResult = invoke(system.openURL, url)
if opened and openResult ~= false then
return true, url
end
local copied, copyResult = invoke(system.setClipboardText, url)
if copied and copyResult ~= false then
return true, url, "Issue URL copied to the clipboard."
end
local filesystem = love and love.filesystem or {}
local written, writeResult = invoke(filesystem.write, "issue-report-url.txt", url)
if written and writeResult ~= false then
return true, url, "Issue URL saved to issue-report-url.txt."
end
return false, url, "No browser, clipboard, or writable save directory is available for the issue report."
end
IssueReport.percentEncode = percentEncode
IssueReport.metadata = metadata
return IssueReport
+11
View File
@@ -300,6 +300,7 @@ function SaveData.defaultOptions()
-- Native mod enablement is an installation option, not save-slot data. -- Native mod enablement is an installation option, not save-slot data.
-- Missing entries mean enabled so newly installed mods work by default. -- Missing entries mean enabled so newly installed mods work by default.
mods = {}, mods = {},
safeMode = false,
-- Mods the player forced past the target gate (Loader:_gateGeneration). -- Mods the player forced past the target gate (Loader:_gateGeneration).
-- modsGen2[id][version] = true, one answer per game; a bare `true` is the -- modsGen2[id][version] = true, one answer per game; a bare `true` is the
-- pre-per-game shape and means the Gen 2 games only (see modForced). -- pre-per-game shape and means the Gen 2 games only (see modForced).
@@ -386,6 +387,16 @@ function SaveData.mergeOptions(loaded)
return opts return opts
end end
function SaveData.isSafeMode(options)
return type(options) == "table" and options.safeMode == true
end
function SaveData.setSafeMode(options, enabled)
if type(options) ~= "table" then return false end
options.safeMode = enabled == true
return options.safeMode
end
function SaveData.encode(data) function SaveData.encode(data)
return SaveSerializer.encode(data) return SaveSerializer.encode(data)
end end
+45 -1
View File
@@ -422,7 +422,7 @@ local function discoverModSchemas(opts)
-- except experimental mods, which stay off until opted in. -- except experimental mods, which stay off until opted in.
local flag = require("src.core.SaveData").modEnabled(opts, m.id) local flag = require("src.core.SaveData").modEnabled(opts, m.id)
local enabled = flag == true or (flag == nil and not m.experimental) local enabled = flag == true or (flag == nil and not m.experimental)
if enabled then if enabled and not SaveData.isSafeMode(opts) then
local chunk = fs.load(path .. "/" .. m.options_schema) local chunk = fs.load(path .. "/" .. m.options_schema)
if chunk then if chunk then
local okR, schema = pcall(chunk) local okR, schema = pcall(chunk)
@@ -521,9 +521,49 @@ local function modRows(opts, mod)
return true return true
end } end }
end end
for _, row in ipairs(rows) do
row.safeModeBlocked = true
if row.step then
local step = row.step
row.step = function(dir)
if SaveData.isSafeMode(opts) then return false end
return step(dir)
end
end
if row.setText then
local setText = row.setText
row.setText = function(text)
if SaveData.isSafeMode(opts) then return false end
return setText(text)
end
end
end
return rows return rows
end end
local function troubleshootingRows(opts, hooks)
return {
{
label = Strings("SAFE MODE"),
actionLabel = function()
return SaveData.isSafeMode(opts) and Strings("Turn off") or Strings("Turn on")
end,
action = function()
SaveData.setSafeMode(opts, not SaveData.isSafeMode(opts))
return true
end,
},
{
label = Strings("REPORT ISSUE"),
actionLabel = Strings("Report bug"),
action = function()
if hooks and hooks.reportIssue then hooks.reportIssue(opts) end
return false
end,
},
}
end
-- ------- Gen 2 (Gold) -- ------- Gen 2 (Gold)
-- --
-- Gold reads NONE of the rows above. Its OPTION screen writes a different -- Gold reads NONE of the rows above. Its OPTION screen writes a different
@@ -704,6 +744,10 @@ function LauncherSettings.open(hooks, version)
sections[#sections + 1] = { title = mod.name, rows = rows } sections[#sections + 1] = { title = mod.name, rows = rows }
end end
end end
sections[#sections + 1] = {
title = Strings("TROUBLESHOOTING"),
rows = troubleshootingRows(opts, hooks),
}
return { return {
opts = opts, opts = opts,
version = version, version = version,
+31 -11
View File
@@ -682,6 +682,7 @@ end
local function modStatusColor(status) local function modStatusColor(status)
if status == "ok" then return Strings("Ready"), PAL.green end if status == "ok" then return Strings("Ready"), PAL.green end
if status == "safe_mode" then return Strings("Safe mode"), PAL.yellow end
if status == "needs_import" then return Strings("Import required"), PAL.yellow end if status == "needs_import" then return Strings("Import required"), PAL.yellow end
if status == "conflict" then return Strings("Conflict"), PAL.red end if status == "conflict" then return Strings("Conflict"), PAL.red end
-- not a fault: the mod is intact, this is simply not a game it is for -- not a fault: the mod is intact, this is simply not a game it is for
@@ -1814,10 +1815,11 @@ end
-- One compact coloured checkbox for each game. The cartridge colour carries -- One compact coloured checkbox for each game. The cartridge colour carries
-- the game identity even when the row is narrow. -- the game identity even when the row is narrow.
local function modGameCheckbox(x, y, size, checked, game, id) local function modGameCheckbox(x, y, size, checked, game, id, enabled)
local color = cartColor(game) enabled = enabled ~= false
local focused = Kit.focusable(id, x, y, size, size) local color = enabled and cartColor(game) or PAL.steel
local hot = focused or Kit.hover(x, y, size, size) local focused = enabled and Kit.focusable(id, x, y, size, size)
local hot = enabled and (focused or Kit.hover(x, y, size, size))
if love.graphics then if love.graphics then
Theme.fillRounded(x, y, size, size, PAL.bg, 1) Theme.fillRounded(x, y, size, size, PAL.bg, 1)
if checked then if checked then
@@ -1829,12 +1831,13 @@ local function modGameCheckbox(x, y, size, checked, game, id)
hot and Theme.A.focus or Theme.A.hairline, 1) hot and Theme.A.focus or Theme.A.hairline, 1)
end end
end end
return Kit.press(x, y, size, size) or Kit._activateId == id return enabled and (Kit.press(x, y, size, size) or Kit._activateId == id)
end end
local function buildModsPanel(imp, x, y, w, availH, m) local function buildModsPanel(imp, x, y, w, availH, m)
imp:_ensureMods() imp:_ensureMods()
local ModUpdate = require("src.mods.ModUpdate") local ModUpdate = require("src.mods.ModUpdate")
local safeMode = imp.safeMode == true
local mods = imp.mods or {} local mods = imp.mods or {}
local gap = m.gap local gap = m.gap
local cy = y local cy = y
@@ -1866,9 +1869,11 @@ local function buildModsPanel(imp, x, y, w, availH, m)
action = function() imp:chooseMod() end }) action = function() imp:chooseMod() end })
btn(imp, place(disableW), cy, disableW, bh, "mods-disable-all", Strings("Disable all"), { btn(imp, place(disableW), cy, disableW, bh, "mods-disable-all", Strings("Disable all"), {
kind = "warn", font = "small", kind = "warn", font = "small",
enabled = not safeMode,
action = function() imp:_setAllMods(false) end }) action = function() imp:_setAllMods(false) end })
btn(imp, place(enableW), cy, enableW, bh, "mods-enable-all", Strings("Enable all"), { btn(imp, place(enableW), cy, enableW, bh, "mods-enable-all", Strings("Enable all"), {
kind = "good", font = "small", kind = "good", font = "small",
enabled = not safeMode,
action = function() imp:_setAllMods(true) end }) action = function() imp:_setAllMods(true) end })
btn(imp, place(checkFullW), cy, checkFullW, bh, "mods-check-updates", Strings("Check for updates"), { btn(imp, place(checkFullW), cy, checkFullW, bh, "mods-check-updates", Strings("Check for updates"), {
font = "small", font = "small",
@@ -1917,7 +1922,9 @@ local function buildModsPanel(imp, x, y, w, availH, m)
-- notice line -- notice line
local noticeText, noticeCol local noticeText, noticeCol
if imp.modNotice then if safeMode then
noticeText, noticeCol = "Safe mode is on. All mods are disabled. Turn it off in Settings to change mod toggles.", PAL.yellow
elseif imp.modNotice then
noticeText = imp.modNotice.text noticeText = imp.modNotice.text
noticeCol = imp.modNotice.ok and PAL.green or PAL.red noticeCol = imp.modNotice.ok and PAL.green or PAL.red
else else
@@ -2043,7 +2050,7 @@ local function buildModsPanel(imp, x, y, w, availH, m)
local togKey = "mod-toggle-" .. mod.id .. "-" .. game local togKey = "mod-toggle-" .. mod.id .. "-" .. game
if modGameCheckbox(tx, gamesY, togH, if modGameCheckbox(tx, gamesY, togH,
mod.enabledByVersion and mod.enabledByVersion[game] == true, mod.enabledByVersion and mod.enabledByVersion[game] == true,
game, togKey) then game, togKey, not safeMode) then
local version = game local version = game
queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end) queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end)
flipped = true flipped = true
@@ -3079,6 +3086,7 @@ local function buildProfilesModal(imp, m)
btn(imp, place(swBtnW), ry + math.floor(4 * m.s), swBtnW, rowH - math.floor(8 * m.s), "prof-sw-" .. i, btn(imp, place(swBtnW), ry + math.floor(4 * m.s), swBtnW, rowH - math.floor(8 * m.s), "prof-sw-" .. i,
Strings("Switch"), { Strings("Switch"), {
kind = "good", font = "micro", kind = "good", font = "micro",
enabled = not imp.safeMode,
action = function() action = function()
LauncherMods.applyProfile(p.name, options) LauncherMods.applyProfile(p.name, options)
if imp._refreshMods then imp:_refreshMods() end if imp._refreshMods then imp:_refreshMods() end
@@ -3105,8 +3113,10 @@ local function buildModHeaderActionsModal(imp, m)
local btns = { local btns = {
{ label = Strings("Mod profiles..."), action = function() imp._profilesPopup = true end }, { label = Strings("Mod profiles..."), action = function() imp._profilesPopup = true end },
{ label = Strings("Check for updates"), action = function() imp:_syncModUpdateInfo(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("Enable all mods"), kind = "good", enabled = not imp.safeMode,
{ label = Strings("Disable all mods"), kind = "warn", action = function() imp:_setAllMods(false) end }, action = function() imp:_setAllMods(true) end },
{ label = Strings("Disable all mods"), kind = "warn", enabled = not imp.safeMode,
action = function() imp:_setAllMods(false) end },
{ label = Strings("Sort mods..."), action = function() imp._sortPopup = "mods" end }, { label = Strings("Sort mods..."), action = function() imp._sortPopup = "mods" end },
} }
local h = pad + Kit.textHeight("button") + math.floor(12 * m.s) local h = pad + Kit.textHeight("button") + math.floor(12 * m.s)
@@ -3119,6 +3129,7 @@ local function buildModHeaderActionsModal(imp, m)
for i, b in ipairs(btns) do for i, b in ipairs(btns) do
btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modheadact-" .. i, b.label, { btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modheadact-" .. i, b.label, {
kind = b.kind or "ghost", font = "small", kind = b.kind or "ghost", font = "small",
enabled = b.enabled,
action = function() action = function()
imp._modHeaderActionsPopup = nil imp._modHeaderActionsPopup = nil
b.action() b.action()
@@ -3737,6 +3748,7 @@ end
local function buildSettingsModal(imp, m) local function buildSettingsModal(imp, m)
local model = imp._settings local model = imp._settings
local SaveData = require("src.core.SaveData")
local pad = math.floor(18 * m.s) local pad = math.floor(18 * m.s)
local w = math.floor(640 * m.s) local w = math.floor(640 * m.s)
local h = math.floor(math.min(m.H - 2 * m.pad, m.H * 0.9)) local h = math.floor(math.min(m.H - 2 * m.pad, m.H * 0.9))
@@ -3826,6 +3838,8 @@ local function buildSettingsModal(imp, m)
item.header) item.header)
else else
local row = item.row local row = item.row
local rowEnabled = not row.safeModeBlocked
or not SaveData.isSafeMode(model.opts)
local key = "set-" .. i local key = "set-" .. i
Kit.card(px + pad, ry, pw - 2 * pad, rowH, "hairline") Kit.card(px + pad, ry, pw - 2 * pad, rowH, "hairline")
local ix = px + pad + math.floor(12 * m.s) local ix = px + pad + math.floor(12 * m.s)
@@ -3855,6 +3869,7 @@ local function buildSettingsModal(imp, m)
ctlY + (m.btnH - Kit.textHeight("small")) / 2, PAL.detail) ctlY + (m.btnH - Kit.textHeight("small")) / 2, PAL.detail)
btn(imp, rx - ew, ctlY, ew, m.btnH, btn(imp, rx - ew, ctlY, ew, m.btnH,
key .. "-edit", Strings("Edit"), { kind = "accent", font = "small", key .. "-edit", Strings("Edit"), { kind = "accent", font = "small",
enabled = rowEnabled,
action = function() action = function()
imp._settingsText = { row = row, text = tostring(row.value() or ""), imp._settingsText = { row = row, text = tostring(row.value() or ""),
maxLen = row.editText.maxLen } maxLen = row.editText.maxLen }
@@ -3863,12 +3878,14 @@ local function buildSettingsModal(imp, m)
elseif row.action then elseif row.action then
-- A plain action row (Reset rebinds, Touch controls): the whole right -- A plain action row (Reset rebinds, Touch controls): the whole right
-- side is one button rather than a value ladder. -- side is one button rather than a value ladder.
local aw = Kit.textWidth("small", row.actionLabel or Strings("Run")) local actionLabel = type(row.actionLabel) == "function"
and row.actionLabel() or row.actionLabel or Strings("Run")
local aw = Kit.textWidth("small", actionLabel)
+ math.floor(24 * m.s) + math.floor(24 * m.s)
Kit.text("small", Kit.ellipsize("small", row.label, Kit.text("small", Kit.ellipsize("small", row.label,
labelW or (inner - aw - math.floor(12 * m.s))), ix, labelY, PAL.text) labelW or (inner - aw - math.floor(12 * m.s))), ix, labelY, PAL.text)
btn(imp, rx - aw, ctlY, aw, m.btnH, btn(imp, rx - aw, ctlY, aw, m.btnH,
key .. "-act", row.actionLabel or Strings("Run"), { key .. "-act", actionLabel, {
kind = row.danger and "danger" or "ghost", font = "small", kind = row.danger and "danger" or "ghost", font = "small",
action = function() action = function()
if row.action() ~= false then model.save() end if row.action() ~= false then model.save() end
@@ -3884,12 +3901,14 @@ local function buildSettingsModal(imp, m)
or valW or valW
btn(imp, rx - stepW, ctlY, stepW, m.btnH, btn(imp, rx - stepW, ctlY, stepW, m.btnH,
key .. "-next", ">", { font = "small", key .. "-next", ">", { font = "small",
enabled = rowEnabled,
action = function() if row.step and row.step(1) then model.save() end end }) action = function() if row.step and row.step(1) then model.save() end end })
Kit.textCenter("small", Kit.ellipsize("small", tostring(row.value()), vw), Kit.textCenter("small", Kit.ellipsize("small", tostring(row.value()), vw),
rx - stepW - vw, ctlY + (m.btnH - Kit.textHeight("small")) / 2, vw, rx - stepW - vw, ctlY + (m.btnH - Kit.textHeight("small")) / 2, vw,
PAL.heading) PAL.heading)
btn(imp, rx - stepW - vw - stepW, ctlY, stepW, btn(imp, rx - stepW - vw - stepW, ctlY, stepW,
m.btnH, key .. "-prev", "<", { font = "small", m.btnH, key .. "-prev", "<", { font = "small",
enabled = rowEnabled,
action = function() if row.step and row.step(-1) then model.save() end end }) action = function() if row.step and row.step(-1) then model.save() end end })
end end
end end
@@ -4046,6 +4065,7 @@ local function buildDepResolverModal(imp, m)
local bw = Kit.textWidth("small", btnLabel) + math.floor(20 * m.s) local bw = Kit.textWidth("small", btnLabel) + math.floor(20 * m.s)
btn(imp, place(bw), ly, bw, chipH, "dep-dis-" .. i, btnLabel, { btn(imp, place(bw), ly, bw, chipH, "dep-dis-" .. i, btnLabel, {
kind = "warn", font = "small", kind = "warn", font = "small",
enabled = not imp.safeMode,
action = function() action = function()
local LauncherMods = require("src.mods.LauncherMods") local LauncherMods = require("src.mods.LauncherMods")
LauncherMods.setEnabled(dep.id, false, imp.modScope) LauncherMods.setEnabled(dep.id, false, imp.modScope)
+49 -3
View File
@@ -3625,6 +3625,10 @@ function RomImporter:_openSettings()
-- The tab rides along: the editor persists the layout into that game's own -- The tab rides along: the editor persists the layout into that game's own
-- option block, and Gold's is not the flat Gen 1 one (#1100). -- option block, and Gold's is not the flat Gen 1 one (#1100).
local hooks = {} local hooks = {}
local version = self.tab
hooks.reportIssue = function(opts)
return self:_reportIssue(opts, version)
end
if self.onEditTouchControls then if self.onEditTouchControls then
local version = self.tab local version = self.tab
hooks.editTouchControls = function() hooks.editTouchControls = function()
@@ -3642,11 +3646,13 @@ function RomImporter:_openSettings()
-- The tab the gear was opened on decides the row set: Gold reads a -- The tab the gear was opened on decides the row set: Gold reads a
-- different option block entirely, and offering it Gen 1's rows meant a -- different option block entirely, and offering it Gen 1's rows meant a
-- dozen controls that changed nothing (see LauncherSettings.gen2Rows). -- dozen controls that changed nothing (see LauncherSettings.gen2Rows).
local version = self.tab
local ok, model = pcall(function() local ok, model = pcall(function()
return require("src.import.LauncherSettings").open(hooks, version) return require("src.import.LauncherSettings").open(hooks, version)
end) end)
if ok and model then self._settings = model end if ok and model then
self._settings = model
self._settingsSafeModeAtOpen = require("src.core.SaveData").isSafeMode(model.opts)
end
end end
-- Quit from the launcher's own X. It goes through love.event.quit so main.lua's -- Quit from the launcher's own X. It goes through love.event.quit so main.lua's
@@ -3657,8 +3663,38 @@ function RomImporter:_quitApp()
end end
function RomImporter:_closeSettings() function RomImporter:_closeSettings()
if self._settings then self._settings.save() end local model = self._settings
if model then
model.save()
local safeMode = require("src.core.SaveData").isSafeMode(model.opts)
if safeMode ~= self._settingsSafeModeAtOpen then
self.mods = nil
self.safeMode = safeMode
self._modSortCache = nil
self._modInfoFetch = nil
end
end
self._settings = nil self._settings = nil
self._settingsSafeModeAtOpen = nil
end
function RomImporter:_reportIssue(options, version)
local ok, IssueReport = pcall(require, "src.core.IssueReport")
if not ok then
self.modNotice = { ok = false, text = "Could not prepare the issue report." }
return false
end
local opened, url, reason = IssueReport.open(options, {
version = version,
mods = self.mods,
})
if not opened then
self.modNotice = { ok = false, text = reason or "Could not open the issue report." }
return false
end
self._lastIssueReportURL = url
if reason then self.modNotice = { ok = true, text = reason } end
return true
end end
function RomImporter:_commitSettingsText() function RomImporter:_commitSettingsText()
@@ -3998,6 +4034,8 @@ end
-- so a still list costs nothing after the first paint. -- so a still list costs nothing after the first paint.
function RomImporter:_refreshMods() function RomImporter:_refreshMods()
local LauncherMods = require("src.mods.LauncherMods") local LauncherMods = require("src.mods.LauncherMods")
local SaveData = require("src.core.SaveData")
self.safeMode = SaveData.isSafeMode(SaveData.loadOptions())
-- Once per session, ahead of the first listing: pull in any mod the player -- Once per session, ahead of the first listing: pull in any mod the player
-- unzipped beside the executable, which an ordinary (non-portable) install -- unzipped beside the executable, which an ordinary (non-portable) install
-- has no way to read. It happens here rather than behind a button because -- has no way to read. It happens here rather than behind a button because
@@ -4137,6 +4175,10 @@ end
-- so that game's checkbox and status chips reflect the new resolution. -- so that game's checkbox and status chips reflect the new resolution.
-- Enabling an experimental mod arms a confirmation for that same game. -- Enabling an experimental mod arms a confirmation for that same game.
function RomImporter:_toggleMod(id, confirmed, version) function RomImporter:_toggleMod(id, confirmed, version)
if self.safeMode then
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." }
return
end
local LauncherMods = require("src.mods.LauncherMods") local LauncherMods = require("src.mods.LauncherMods")
local cur, experimental = false, false local cur, experimental = false, false
for _, m in ipairs(self.mods or {}) do for _, m in ipairs(self.mods or {}) do
@@ -4177,6 +4219,10 @@ end
-- must not be the way around it. Disabling needs no confirm -- it is the -- must not be the way around it. Disabling needs no confirm -- it is the
-- recovery action, and Delete is the only destructive one on this panel. -- recovery action, and Delete is the only destructive one on this panel.
function RomImporter:_setAllMods(want, confirmed) function RomImporter:_setAllMods(want, confirmed)
if self.safeMode then
self.modNotice = { ok = false, text = "Safe mode is active. Turn it off in Settings to change mods." }
return
end
local LauncherMods = require("src.mods.LauncherMods") local LauncherMods = require("src.mods.LauncherMods")
local ids, experimental = {}, false local ids, experimental = {}, false
for _, m in ipairs(self.mods or {}) do for _, m in ipairs(self.mods or {}) do
+16 -3
View File
@@ -291,6 +291,7 @@ function LauncherMods.deriveList(manifests, options, version)
local ordered = {} local ordered = {}
for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end for _, m in ipairs(manifests) do ordered[#ordered + 1] = m end
table.sort(ordered, function(a, b) return a.id < b.id end) table.sort(ordered, function(a, b) return a.id < b.id end)
local safeMode = SaveData.isSafeMode(options)
-- the override is one answer per game (SaveData.modForced), the same scope -- the override is one answer per game (SaveData.modForced), the same scope
-- the loader resolves it under -- the loader resolves it under
@@ -304,17 +305,24 @@ function LauncherMods.deriveList(manifests, options, version)
-- matching the loader -- except experimental mods, which stay off until -- matching the loader -- except experimental mods, which stay off until
-- the player opts in. Scoped through modScope, so this reads exactly what -- the player opts in. Scoped through modScope, so this reads exactly what
-- setEnabled writes and the loader loads for the selected game. -- setEnabled writes and the loader loads for the selected game.
if not safeMode then
local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version)) local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version))
if decided == nil then decided = not m.experimental end if decided == nil then decided = not m.experimental end
if decided then enabledSet[m.id] = true end if decided then enabledSet[m.id] = true end
end end
end
local out = {} local out = {}
for _, m in ipairs(ordered) do for _, m in ipairs(ordered) do
local enabled = enabledSet[m.id] == true local enabled = not safeMode and enabledSet[m.id] == true
local forced = forcedFor(m.id) local forced = forcedFor(m.id)
local status, detail = local status, detail
if safeMode then
status, detail = "safe_mode", "Disabled by safe mode"
else
status, detail =
statusFor(byId, m.id, enabledSet, enabled, version, forcedFor) statusFor(byId, m.id, enabledSet, enabled, version, forcedFor)
end
-- nil, not false, when the panel is showing every game at once -- nil, not false, when the panel is showing every game at once
local here = nil local here = nil
if version then here = ModTargets.runsHere(m, version, nil, forced) end if version then here = ModTargets.runsHere(m, version, nil, forced) end
@@ -332,7 +340,8 @@ function LauncherMods.deriveList(manifests, options, version)
local answers = {} local answers = {}
for _, game in ipairs(GameVersion.ORDER) do for _, game in ipairs(GameVersion.ORDER) do
local answer = SaveData.modEnabled(options, m.id, game) local answer = SaveData.modEnabled(options, m.id, game)
answers[game] = answer == true or (answer == nil and not m.experimental) answers[game] = not safeMode
and (answer == true or (answer == nil and not m.experimental))
end end
return answers return answers
end)(), end)(),
@@ -346,6 +355,7 @@ function LauncherMods.deriveList(manifests, options, version)
-- panel is showing (src/mods/ModTargets.lua) -- panel is showing (src/mods/ModTargets.lua)
targets = ModTargets.chip(m), targets = ModTargets.chip(m),
targetsHere = here, targetsHere = here,
safeMode = safeMode,
} }
end end
return out return out
@@ -586,6 +596,7 @@ end
-- answer. The loader and the in-game manager use the same scope on next boot. -- answer. The loader and the in-game manager use the same scope on next boot.
function LauncherMods.setEnabled(id, enabled, version) function LauncherMods.setEnabled(id, enabled, version)
local options = SaveData.loadOptions() local options = SaveData.loadOptions()
if SaveData.isSafeMode(options) then return false end
SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version)) SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version))
SaveData.saveOptions(options) SaveData.saveOptions(options)
LauncherMods.syncActiveProfile(options) LauncherMods.syncActiveProfile(options)
@@ -599,6 +610,7 @@ end
-- and leaves a half-applied state behind if one of them fails. -- and leaves a half-applied state behind if one of them fails.
function LauncherMods.setAllEnabled(ids, enabled, version) function LauncherMods.setAllEnabled(ids, enabled, version)
local options = SaveData.loadOptions() local options = SaveData.loadOptions()
if SaveData.isSafeMode(options) then return false end
local scope = SaveData.modScope(version) local scope = SaveData.modScope(version)
for _, id in ipairs(ids or {}) do for _, id in ipairs(ids or {}) do
if scope then if scope then
@@ -1184,6 +1196,7 @@ end
function LauncherMods.applyProfile(profileName, options) function LauncherMods.applyProfile(profileName, options)
options = options or SaveData.loadOptions() options = options or SaveData.loadOptions()
if SaveData.isSafeMode(options) then return false end
local profiles = options.modProfiles or {} local profiles = options.modProfiles or {}
local targetProfile local targetProfile
for _, p in ipairs(profiles) do for _, p in ipairs(profiles) do
+10 -1
View File
@@ -259,6 +259,7 @@ function Loader.new(opts)
modInput = {}, modEnv = {}, stepsQueues = {}, modInput = {}, modEnv = {}, stepsQueues = {},
fs = (opts and opts.fs) or (love and love.filesystem), fs = (opts and opts.fs) or (love and love.filesystem),
dev = dev, dev = dev,
safeMode = false,
-- Which generation this boot is (1 or 2). Fixed at construction: the -- Which generation this boot is (1 or 2). Fixed at construction: the
-- active version is set once in main.lua's bootGame before anything -- active version is set once in main.lua's bootGame before anything
-- builds a loader, and a run never changes generation underneath one. -- builds a loader, and a run never changes generation underneath one.
@@ -300,6 +301,8 @@ end
function Loader:_loadState() function Loader:_loadState()
self.disabled = {} self.disabled = {}
local options = SaveData.loadOptions(self.fs) local options = SaveData.loadOptions(self.fs)
self.safeMode = SaveData.isSafeMode(options)
Runtime.safeMode = self.safeMode
local scope = self:_enableScope() local scope = self:_enableScope()
local ids = {} local ids = {}
for id in pairs(options.mods or {}) do ids[id] = true end for id in pairs(options.mods or {}) do ids[id] = true end
@@ -415,6 +418,7 @@ function Loader:_writeOptionSchemas()
end end
function Loader:setEnabled(id, enabled) function Loader:setEnabled(id, enabled)
if self.safeMode then return false end
if not self.mods[id] then return false end if not self.mods[id] then return false end
self.disabled[id] = not enabled self.disabled[id] = not enabled
self.mods[id].enabled = enabled self.mods[id].enabled = enabled
@@ -427,6 +431,7 @@ end
-- choice could not be persisted for a game, so the caller does not promise a -- choice could not be persisted for a game, so the caller does not promise a
-- restart will honour it. -- restart will honour it.
function Loader:setGen2Forced(id, forced) function Loader:setGen2Forced(id, forced)
if self.safeMode then return false, false end
if not self.mods[id] then return false, false end if not self.mods[id] then return false, false end
self.gen2Forced[id] = forced or nil self.gen2Forced[id] = forced or nil
self:_saveState() self:_saveState()
@@ -1539,6 +1544,9 @@ function Loader:load(data)
require("src.mods.Builtins").install(self.content, data, self.generation) require("src.mods.Builtins").install(self.content, data, self.generation)
self:_loadState() self:_loadState()
self:_discover() self:_discover()
if self.safeMode then
for id in pairs(self.mods) do self.disabled[id] = true end
end
-- Existing installs stored one shared answer. Once their manifests are -- Existing installs stored one shared answer. Once their manifests are
-- known, split that answer across every game before the next launcher/game -- known, split that answer across every game before the next launcher/game
-- toggle can change one independently. _loadState already used the same -- toggle can change one independently. _loadState already used the same
@@ -1574,7 +1582,7 @@ function Loader:load(data)
-- the one build where its env var is set. -- the one build where its env var is set.
for id, mod in pairs(self.mods) do for id, mod in pairs(self.mods) do
local envName = mod.manifest.force_enable_env local envName = mod.manifest.force_enable_env
if envName and os.getenv(envName) == "1" then if not self.safeMode and envName and os.getenv(envName) == "1" then
self.disabled[id] = nil self.disabled[id] = nil
end end
end end
@@ -1740,6 +1748,7 @@ function Loader:status()
local manifest = {} local manifest = {}
for key, value in pairs(mod.manifest) do manifest[key] = value end for key, value in pairs(mod.manifest) do manifest[key] = value end
manifest.enabled = mod.enabled ~= false manifest.enabled = mod.enabled ~= false
manifest.safeMode = self.safeMode == true
manifest.state = mod.state or (manifest.enabled and "loaded" or "disabled") manifest.state = mod.state or (manifest.enabled and "loaded" or "disabled")
manifest.error = mod.failure manifest.error = mod.failure
-- set instead of `error` when the mod was left out for a reason that is -- set instead of `error` when the mod was left out for a reason that is
+35 -2
View File
@@ -365,9 +365,13 @@ end
function ManagerState:detailRows(m) function ManagerState:detailRows(m)
local rows = {} local rows = {}
if Runtime.safeMode then
rows[#rows + 1] = { label = "SAFE MODE ACTIVE", inert = true }
else
rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE", rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE",
action = function() self:beginToggle(m) end } action = function() self:beginToggle(m) end }
if self:schemaFor(m) then end
if not Runtime.safeMode and self:schemaFor(m) then
rows[#rows + 1] = { label = Strings("OPTIONS.."), rows[#rows + 1] = { label = Strings("OPTIONS.."),
action = function() self:openOptions(m) end } action = function() self:openOptions(m) end }
end end
@@ -383,7 +387,8 @@ function ManagerState:detailRows(m)
-- what this mod does. -- what this mod does.
local loader = self.game.mods local loader = self.game.mods
local version, gen = self:targetGame() local version, gen = self:targetGame()
if loader and loader.setGen2Forced and not ModTargets.supports(m, version, gen) then if loader and loader.setGen2Forced and not Runtime.safeMode
and not ModTargets.supports(m, version, gen) then
rows[#rows + 1] = { rows[#rows + 1] = {
label = m.gen2Forced and Strings("DON'T TRY HERE") or Strings("TRY HERE ANYWAY"), label = m.gen2Forced and Strings("DON'T TRY HERE") or Strings("TRY HERE ANYWAY"),
action = function() self:toggleGen2Force(m) end } action = function() self:toggleGen2Force(m) end }
@@ -674,6 +679,10 @@ end
-- ------- the enable/disable flow -- ------- the enable/disable flow
function ManagerState:beginToggle(m) function ManagerState:beginToggle(m)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
if not m then return end if not m then return end
local want = not m.enabled local want = not m.enabled
local loader = self.game.mods local loader = self.game.mods
@@ -711,6 +720,10 @@ end
-- override is scoped to THIS game, and a boot that cannot name one keeps it in -- override is scoped to THIS game, and a boot that cannot name one keeps it in
-- memory only, which the notice says rather than promising a restart. -- memory only, which the notice says rather than promising a restart.
function ManagerState:toggleGen2Force(m) function ManagerState:toggleGen2Force(m)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local loader = self.game.mods local loader = self.game.mods
if not (loader and loader.setGen2Forced) then return end if not (loader and loader.setGen2Forced) then return end
local want = not m.gen2Forced local want = not m.gen2Forced
@@ -743,6 +756,10 @@ function ManagerState:enableScope()
end end
function ManagerState:commitToggle(apply) function ManagerState:commitToggle(apply)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local loader = self.game.mods local loader = self.game.mods
local opts = self:optionsTable() local opts = self:optionsTable()
local scope = self:enableScope() local scope = self:enableScope()
@@ -757,6 +774,10 @@ function ManagerState:commitToggle(apply)
end end
function ManagerState:discardChanges() function ManagerState:discardChanges()
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local loader = self.game.mods local loader = self.game.mods
local opts = self:optionsTable() local opts = self:optionsTable()
local scope = self:enableScope() local scope = self:enableScope()
@@ -802,6 +823,10 @@ function ManagerState:persistOptions()
end end
function ManagerState:applyProfile(p) function ManagerState:applyProfile(p)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local mods = self:manifestMap() local mods = self:manifestMap()
local set = self:enabledSet() local set = self:enabledSet()
local combined = {} local combined = {}
@@ -963,6 +988,10 @@ function ManagerState:optionValue(modId, row)
end end
function ManagerState:setOption(modId, key, value) function ManagerState:setOption(modId, key, value)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return false
end
local save = self.game.save local save = self.game.save
if save and save.options then if save and save.options then
save.options.modOptions = save.options.modOptions or {} save.options.modOptions = save.options.modOptions or {}
@@ -1123,6 +1152,10 @@ function ManagerState:buildOptionRows(m, schema)
end end
function ManagerState:openOptions(m) function ManagerState:openOptions(m)
if Runtime.safeMode then
self:notify("SAFE MODE ACTIVE")
return
end
local schema = self:schemaFor(m) local schema = self:schemaFor(m)
if not schema then if not schema then
self:notify("NO OPTIONS") self:notify("NO OPTIONS")
+2
View File
@@ -33,6 +33,8 @@ Runtime.currentMod = nil
-- currentMod went back to nil (src/mods/Sandbox.lua) -- currentMod went back to nil (src/mods/Sandbox.lua)
Runtime.modRequire = nil Runtime.modRequire = nil
Runtime.safeMode = false
function Runtime.install(events, hooks, errors) function Runtime.install(events, hooks, errors)
Runtime.events, Runtime.hooks = events, hooks Runtime.events, Runtime.hooks = events, hooks
Runtime.errors = errors Runtime.errors = errors
+137
View File
@@ -0,0 +1,137 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local S = require("tests.harness").suite("safe mode and issue report")
local check = S.check
local SaveData = require("src.core.SaveData")
local LauncherMods = require("src.mods.LauncherMods")
local IssueReport = require("src.core.IssueReport")
local Version = require("src.core.Version")
local options = SaveData.defaultOptions()
check(not SaveData.isSafeMode(options), "safe mode defaults off")
SaveData.setSafeMode(options, true)
check(SaveData.isSafeMode(options), "safe mode can be enabled")
local manifests = {
{ id = "alpha", name = "Alpha", version = "1.0.0", experimental = false,
raw = {}, dependencySpecs = {}, conflictSpecs = {} },
{ id = "beta", name = "Beta", version = "1.0.0", experimental = false,
raw = {}, dependencySpecs = {}, conflictSpecs = {} },
}
options.mods.alpha = false
options.mods.beta = true
local rows = LauncherMods.deriveList(manifests, options, "red")
check(#rows == 2, "safe mode keeps installed mods visible")
check(not rows[1].enabled and not rows[2].enabled,
"safe mode disables every launcher mod row")
check(rows[1].status == "safe_mode" and rows[2].status == "safe_mode",
"safe mode explains every disabled launcher row")
SaveData.setSafeMode(options, false)
rows = LauncherMods.deriveList(manifests, options, "red")
local byId = {}
for _, row in ipairs(rows) do byId[row.id] = row end
check(not byId.alpha.enabled and byId.beta.enabled,
"turning safe mode off restores saved mod choices")
local previousLove = _G.love
local openedURL
_G.love = {
getVersion = function() return 12, 0, 0, "Mysterious Mysteries" end,
system = {
getOS = function() return "iOS" end,
getModel = function() return "iPad Test" end,
openURL = function(url) openedURL = url end,
},
graphics = {
getRendererInfo = function()
return "Metal", "3.0", "Apple", "Simulator GPU"
end,
getDimensions = function() return 1024, 768 end,
getPixelDimensions = function() return 2048, 1536 end,
},
window = {
getMode = function() return 1024, 768, { fullscreen = false } end,
},
}
local url, fields, info = IssueReport.build({
safeMode = true,
lastVersion = "gold",
}, {
version = "gold",
mods = { { id = "alpha", name = "Alpha", enabled = true } },
})
check(url:find("template=bug_report.yml", 1, true) ~= nil,
"report URL selects the bug form")
check(url:find("title=bug%3A%20replace%20this%20with%20a%20meaningful%20title", 1, true) ~= nil,
"report URL uses the requested bug title")
check(info.os == "iOS",
"report metadata maps the platform")
check(fields.mods_which == "",
"safe mode leaves the optional mod list blank")
check(not url:find("game=", 1, true)
and not url:find("os=", 1, true)
and not url:find("mods_enabled=", 1, true),
"report URL omits unsupported dropdown and checkbox prefills")
check(fields.summary == "" and fields.location == "" and fields.screenshot == ""
and fields.steps == "" and fields.expected == "",
"report leaves user-entered fields blank")
check(info.metadata:find("Device: iPad Test", 1, true) ~= nil
and info.metadata:find("LÖVE: 12.0.0", 1, true) ~= nil
and info.metadata:find("Safe mode: on", 1, true) ~= nil,
"report metadata includes device and app details")
check(not info.metadata:find("unknown", 1, true),
"report metadata omits unknown values")
check(not info.metadata:find("Game id", 1, true)
and not info.metadata:find("Game:", 1, true)
and not info.metadata:find("Mods:", 1, true)
and not info.metadata:find("Processors", 1, true)
and not info.metadata:find("Power", 1, true),
"report metadata omits redundant system fields")
local previousEngine = Version.engine
Version.engine = "0.1.50"
local _, versionFields, versionInfo = IssueReport.build({}, { mods = {} })
check(versionFields.version == "0.1.50"
and versionInfo.metadata:find("App: gen1recomp v0.1.50", 1, true) ~= nil,
"report uses the stamped app version")
Version.engine = previousEngine
local _, _, developmentInfo = IssueReport.build({}, { mods = {} })
check(not developmentInfo.metadata:find("0.0.0-dev", 1, true),
"report omits an unstamped development version")
local previousOS = love.system.getOS
local previousModel = love.system.getModel
local previousIO = _G.io
love.system.getOS = function() return "OS X" end
love.system.getModel = nil
_G.io = {
popen = function()
return {
read = function() return "MacBookPro18,3" end,
close = function() end,
}
end,
}
local desktopInfo = IssueReport.metadata({}, { mods = {} })
check(desktopInfo.device == "MacBookPro18,3",
"report finds desktop device model when LOVE has no model")
love.system.getOS = function() return "UWP" end
local xboxInfo = IssueReport.metadata({}, { mods = {} })
check(xboxInfo.os == "Xbox", "report maps the Xbox runtime platform")
love.system.getOS = previousOS
love.system.getModel = previousModel
_G.io = previousIO
local opened = IssueReport.open({ safeMode = false }, {
version = "red",
mods = {},
})
check(opened and openedURL and openedURL:find("title=bug%3A%20replace%20this%20with%20a%20meaningful%20title", 1, true) ~= nil,
"report action opens the generated URL")
_G.love = previousLove
S.finish()