feat(launcher): add safe mode and issue reporting

This commit is contained in:
Adrian Castro
2026-08-19 00:44:30 +02:00
parent 48a3140a28
commit 67a170fd6e
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.
-- 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).
@@ -384,6 +385,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