diff --git a/src/core/IssueReport.lua b/src/core/IssueReport.lua new file mode 100644 index 00000000..910c1f74 --- /dev/null +++ b/src/core/IssueReport.lua @@ -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 diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index 2b03df44..e3d977f5 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -300,6 +300,7 @@ function SaveData.defaultOptions() -- Native mod enablement is an installation option, not save-slot data. -- Missing entries mean enabled so newly installed mods work by default. mods = {}, + safeMode = false, -- Mods the player forced past the target gate (Loader:_gateGeneration). -- 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). @@ -386,6 +387,16 @@ function SaveData.mergeOptions(loaded) return opts 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) return SaveSerializer.encode(data) end diff --git a/src/import/LauncherSettings.lua b/src/import/LauncherSettings.lua index 43a820b4..a73d7eed 100644 --- a/src/import/LauncherSettings.lua +++ b/src/import/LauncherSettings.lua @@ -422,7 +422,7 @@ local function discoverModSchemas(opts) -- except experimental mods, which stay off until opted in. local flag = require("src.core.SaveData").modEnabled(opts, m.id) 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) if chunk then local okR, schema = pcall(chunk) @@ -521,9 +521,49 @@ local function modRows(opts, mod) return true 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 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) -- -- 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 } end end + sections[#sections + 1] = { + title = Strings("TROUBLESHOOTING"), + rows = troubleshootingRows(opts, hooks), + } return { opts = opts, version = version, diff --git a/src/import/LauncherView.lua b/src/import/LauncherView.lua index a2f56efe..32593ab8 100644 --- a/src/import/LauncherView.lua +++ b/src/import/LauncherView.lua @@ -682,6 +682,7 @@ end local function modStatusColor(status) 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 == "conflict" then return Strings("Conflict"), PAL.red end -- 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 -- 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) +local function modGameCheckbox(x, y, size, checked, game, id, enabled) + enabled = enabled ~= false + local color = enabled and cartColor(game) or PAL.steel + 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 Theme.fillRounded(x, y, size, size, PAL.bg, 1) 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) 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 local function buildModsPanel(imp, x, y, w, availH, m) imp:_ensureMods() local ModUpdate = require("src.mods.ModUpdate") + local safeMode = imp.safeMode == true local mods = imp.mods or {} local gap = m.gap local cy = y @@ -1866,9 +1869,11 @@ local function buildModsPanel(imp, x, y, w, availH, m) action = function() imp:chooseMod() end }) btn(imp, place(disableW), cy, disableW, bh, "mods-disable-all", Strings("Disable all"), { kind = "warn", font = "small", + enabled = not safeMode, action = function() imp:_setAllMods(false) end }) btn(imp, place(enableW), cy, enableW, bh, "mods-enable-all", Strings("Enable all"), { kind = "good", font = "small", + enabled = not safeMode, action = function() imp:_setAllMods(true) end }) btn(imp, place(checkFullW), cy, checkFullW, bh, "mods-check-updates", Strings("Check for updates"), { font = "small", @@ -1917,7 +1922,9 @@ local function buildModsPanel(imp, x, y, w, availH, m) -- notice line 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 noticeCol = imp.modNotice.ok and PAL.green or PAL.red else @@ -2043,7 +2050,7 @@ local function buildModsPanel(imp, x, y, w, availH, m) local togKey = "mod-toggle-" .. mod.id .. "-" .. game if modGameCheckbox(tx, gamesY, togH, mod.enabledByVersion and mod.enabledByVersion[game] == true, - game, togKey) then + game, togKey, not safeMode) then local version = game queueAction(imp, togKey, function() imp:_toggleMod(mod.id, nil, version) end) 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, Strings("Switch"), { kind = "good", font = "micro", + enabled = not imp.safeMode, action = function() LauncherMods.applyProfile(p.name, options) if imp._refreshMods then imp:_refreshMods() end @@ -3105,8 +3113,10 @@ local function buildModHeaderActionsModal(imp, m) 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("Enable all mods"), kind = "good", enabled = not imp.safeMode, + 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 }, } 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 btn(imp, px + pad, cy, pw - 2 * pad, m.btnH, "modheadact-" .. i, b.label, { kind = b.kind or "ghost", font = "small", + enabled = b.enabled, action = function() imp._modHeaderActionsPopup = nil b.action() @@ -3737,6 +3748,7 @@ end local function buildSettingsModal(imp, m) local model = imp._settings + local SaveData = require("src.core.SaveData") local pad = math.floor(18 * m.s) local w = math.floor(640 * m.s) 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) else local row = item.row + local rowEnabled = not row.safeModeBlocked + or not SaveData.isSafeMode(model.opts) local key = "set-" .. i Kit.card(px + pad, ry, pw - 2 * pad, rowH, "hairline") 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) btn(imp, rx - ew, ctlY, ew, m.btnH, key .. "-edit", Strings("Edit"), { kind = "accent", font = "small", + enabled = rowEnabled, action = function() imp._settingsText = { row = row, text = tostring(row.value() or ""), maxLen = row.editText.maxLen } @@ -3863,12 +3878,14 @@ local function buildSettingsModal(imp, m) elseif row.action then -- A plain action row (Reset rebinds, Touch controls): the whole right -- 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) Kit.text("small", Kit.ellipsize("small", row.label, labelW or (inner - aw - math.floor(12 * m.s))), ix, labelY, PAL.text) 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", action = function() if row.action() ~= false then model.save() end @@ -3884,12 +3901,14 @@ local function buildSettingsModal(imp, m) or valW btn(imp, rx - stepW, ctlY, stepW, m.btnH, key .. "-next", ">", { font = "small", + enabled = rowEnabled, action = function() if row.step and row.step(1) then model.save() end end }) Kit.textCenter("small", Kit.ellipsize("small", tostring(row.value()), vw), rx - stepW - vw, ctlY + (m.btnH - Kit.textHeight("small")) / 2, vw, PAL.heading) btn(imp, rx - stepW - vw - stepW, ctlY, stepW, m.btnH, key .. "-prev", "<", { font = "small", + enabled = rowEnabled, action = function() if row.step and row.step(-1) then model.save() end end }) end end @@ -4046,6 +4065,7 @@ local function buildDepResolverModal(imp, m) 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", + enabled = not imp.safeMode, action = function() local LauncherMods = require("src.mods.LauncherMods") LauncherMods.setEnabled(dep.id, false, imp.modScope) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 34848aac..a0417bb9 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -3625,6 +3625,10 @@ function RomImporter:_openSettings() -- 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). local hooks = {} + local version = self.tab + hooks.reportIssue = function(opts) + return self:_reportIssue(opts, version) + end if self.onEditTouchControls then local version = self.tab hooks.editTouchControls = function() @@ -3642,11 +3646,13 @@ function RomImporter:_openSettings() -- 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 -- dozen controls that changed nothing (see LauncherSettings.gen2Rows). - local version = self.tab local ok, model = pcall(function() return require("src.import.LauncherSettings").open(hooks, version) 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 -- 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 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._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 function RomImporter:_commitSettingsText() @@ -3998,6 +4034,8 @@ end -- so a still list costs nothing after the first paint. function RomImporter:_refreshMods() 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 -- unzipped beside the executable, which an ordinary (non-portable) install -- 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. -- Enabling an experimental mod arms a confirmation for that same game. 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 cur, experimental = false, false 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 -- recovery action, and Delete is the only destructive one on this panel. 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 ids, experimental = {}, false for _, m in ipairs(self.mods or {}) do diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 10922dea..970e9517 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -291,6 +291,7 @@ function LauncherMods.deriveList(manifests, options, version) local ordered = {} for _, m in ipairs(manifests) do ordered[#ordered + 1] = m 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 loader resolves it under @@ -304,17 +305,24 @@ function LauncherMods.deriveList(manifests, options, version) -- matching the loader -- except experimental mods, which stay off until -- the player opts in. Scoped through modScope, so this reads exactly what -- setEnabled writes and the loader loads for the selected game. - local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version)) - if decided == nil then decided = not m.experimental end - if decided then enabledSet[m.id] = true end + if not safeMode then + local decided = SaveData.modEnabled(options, m.id, SaveData.modScope(version)) + if decided == nil then decided = not m.experimental end + if decided then enabledSet[m.id] = true end + end end local out = {} 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 status, detail = - statusFor(byId, m.id, enabledSet, enabled, version, forcedFor) + 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) + end -- nil, not false, when the panel is showing every game at once local here = nil if version then here = ModTargets.runsHere(m, version, nil, forced) end @@ -332,7 +340,8 @@ function LauncherMods.deriveList(manifests, options, version) local answers = {} for _, game in ipairs(GameVersion.ORDER) do 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 return answers end)(), @@ -346,6 +355,7 @@ function LauncherMods.deriveList(manifests, options, version) -- panel is showing (src/mods/ModTargets.lua) targets = ModTargets.chip(m), targetsHere = here, + safeMode = safeMode, } end return out @@ -586,6 +596,7 @@ end -- answer. The loader and the in-game manager use the same scope on next boot. function LauncherMods.setEnabled(id, enabled, version) local options = SaveData.loadOptions() + if SaveData.isSafeMode(options) then return false end SaveData.setModEnabled(options, id, enabled, SaveData.modScope(version)) SaveData.saveOptions(options) LauncherMods.syncActiveProfile(options) @@ -599,6 +610,7 @@ end -- and leaves a half-applied state behind if one of them fails. function LauncherMods.setAllEnabled(ids, enabled, version) local options = SaveData.loadOptions() + if SaveData.isSafeMode(options) then return false end local scope = SaveData.modScope(version) for _, id in ipairs(ids or {}) do if scope then @@ -1184,6 +1196,7 @@ end function LauncherMods.applyProfile(profileName, options) options = options or SaveData.loadOptions() + if SaveData.isSafeMode(options) then return false end local profiles = options.modProfiles or {} local targetProfile for _, p in ipairs(profiles) do diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index 5dbad269..8ec4767d 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -259,6 +259,7 @@ function Loader.new(opts) modInput = {}, modEnv = {}, stepsQueues = {}, fs = (opts and opts.fs) or (love and love.filesystem), dev = dev, + safeMode = false, -- Which generation this boot is (1 or 2). Fixed at construction: the -- active version is set once in main.lua's bootGame before anything -- builds a loader, and a run never changes generation underneath one. @@ -300,6 +301,8 @@ end function Loader:_loadState() self.disabled = {} local options = SaveData.loadOptions(self.fs) + self.safeMode = SaveData.isSafeMode(options) + Runtime.safeMode = self.safeMode local scope = self:_enableScope() local ids = {} for id in pairs(options.mods or {}) do ids[id] = true end @@ -415,6 +418,7 @@ function Loader:_writeOptionSchemas() end function Loader:setEnabled(id, enabled) + if self.safeMode then return false end if not self.mods[id] then return false end self.disabled[id] = not 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 -- restart will honour it. function Loader:setGen2Forced(id, forced) + if self.safeMode then return false, false end if not self.mods[id] then return false, false end self.gen2Forced[id] = forced or nil self:_saveState() @@ -1539,6 +1544,9 @@ function Loader:load(data) require("src.mods.Builtins").install(self.content, data, self.generation) self:_loadState() 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 -- known, split that answer across every game before the next launcher/game -- 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. for id, mod in pairs(self.mods) do 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 end end @@ -1740,6 +1748,7 @@ function Loader:status() local manifest = {} for key, value in pairs(mod.manifest) do manifest[key] = value end manifest.enabled = mod.enabled ~= false + manifest.safeMode = self.safeMode == true manifest.state = mod.state or (manifest.enabled and "loaded" or "disabled") manifest.error = mod.failure -- set instead of `error` when the mod was left out for a reason that is diff --git a/src/mods/ManagerState.lua b/src/mods/ManagerState.lua index 3066888e..5dd87ef2 100644 --- a/src/mods/ManagerState.lua +++ b/src/mods/ManagerState.lua @@ -365,9 +365,13 @@ end function ManagerState:detailRows(m) local rows = {} - rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE", - action = function() self:beginToggle(m) end } - if self:schemaFor(m) then + if Runtime.safeMode then + rows[#rows + 1] = { label = "SAFE MODE ACTIVE", inert = true } + else + rows[#rows + 1] = { label = m.enabled and "DISABLE" or "ENABLE", + action = function() self:beginToggle(m) end } + end + if not Runtime.safeMode and self:schemaFor(m) then rows[#rows + 1] = { label = Strings("OPTIONS.."), action = function() self:openOptions(m) end } end @@ -383,7 +387,8 @@ function ManagerState:detailRows(m) -- what this mod does. local loader = self.game.mods 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] = { label = m.gen2Forced and Strings("DON'T TRY HERE") or Strings("TRY HERE ANYWAY"), action = function() self:toggleGen2Force(m) end } @@ -674,6 +679,10 @@ end -- ------- the enable/disable flow function ManagerState:beginToggle(m) + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return + end if not m then return end local want = not m.enabled 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 -- memory only, which the notice says rather than promising a restart. function ManagerState:toggleGen2Force(m) + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return + end local loader = self.game.mods if not (loader and loader.setGen2Forced) then return end local want = not m.gen2Forced @@ -743,6 +756,10 @@ function ManagerState:enableScope() end function ManagerState:commitToggle(apply) + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return + end local loader = self.game.mods local opts = self:optionsTable() local scope = self:enableScope() @@ -757,6 +774,10 @@ function ManagerState:commitToggle(apply) end function ManagerState:discardChanges() + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return + end local loader = self.game.mods local opts = self:optionsTable() local scope = self:enableScope() @@ -802,6 +823,10 @@ function ManagerState:persistOptions() end function ManagerState:applyProfile(p) + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return + end local mods = self:manifestMap() local set = self:enabledSet() local combined = {} @@ -963,6 +988,10 @@ function ManagerState:optionValue(modId, row) end function ManagerState:setOption(modId, key, value) + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return false + end local save = self.game.save if save and save.options then save.options.modOptions = save.options.modOptions or {} @@ -1123,6 +1152,10 @@ function ManagerState:buildOptionRows(m, schema) end function ManagerState:openOptions(m) + if Runtime.safeMode then + self:notify("SAFE MODE ACTIVE") + return + end local schema = self:schemaFor(m) if not schema then self:notify("NO OPTIONS") diff --git a/src/mods/Runtime.lua b/src/mods/Runtime.lua index be115dea..a6bf7d3b 100644 --- a/src/mods/Runtime.lua +++ b/src/mods/Runtime.lua @@ -33,6 +33,8 @@ Runtime.currentMod = nil -- currentMod went back to nil (src/mods/Sandbox.lua) Runtime.modRequire = nil +Runtime.safeMode = false + function Runtime.install(events, hooks, errors) Runtime.events, Runtime.hooks = events, hooks Runtime.errors = errors diff --git a/tests/engine/safe_mode_issue_report.lua b/tests/engine/safe_mode_issue_report.lua new file mode 100644 index 00000000..428bbcd8 --- /dev/null +++ b/tests/engine/safe_mode_issue_report.lua @@ -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()