Merge origin/dev (v0.1.74) into feat/switch-ota-unified-launcher.

Keep Switch version chip alongside upstream's new quit button in the launcher header.
This commit is contained in:
Andrew Quenehen
2026-08-06 09:28:26 -03:00
115 changed files with 10474 additions and 2013 deletions
+3
View File
@@ -294,6 +294,9 @@ function CacheFs.read(rel)
f:close()
return data
end
-- headless (plain luajit, e.g. the modkit validate/pack driver): there is
-- no save directory to read from, so a cache miss is nil, not a crash
if not (love and love.filesystem) then return nil end
return love.filesystem.read(rel)
end
+12
View File
@@ -242,6 +242,18 @@ local function coreRows(opts)
opts.touchControls = tc
return true
end)
-- VIBRATION sits with it (#806): same gate, same subsystem. Stepping
-- the row buzzes once at the level being selected.
local okTC, TC = pcall(require, "src.core.TouchControls")
if okTC then
add(Strings("VIBRATION"),
function() return Strings(TC.hapticLabel(opts.haptics)) end,
function(dir)
opts.haptics = TC.cycleHaptics(opts.haptics, dir)
TC.buzz(opts.haptics)
return true
end)
end
end
end
+47 -3
View File
@@ -312,8 +312,20 @@ local function setPage(imp, key, v)
imp._pages[key] = v
end
-- A hand-drawn X, for the same reason drawCheck exists below: the UI font has
-- no guaranteed glyph, and the launcher ships no icon asset for it.
local function drawCross(x, y, size, color)
love.graphics.push("all")
love.graphics.setColor(color)
love.graphics.setLineWidth(math.max(2, size * 0.16))
love.graphics.setLineJoin("bevel")
love.graphics.line(x, y, x + size, y + size)
love.graphics.line(x + size, y, x, y + size)
love.graphics.pop()
end
-- ------------------------------------------------------------- header
-- Rail, logo row (settings on the right), tab bar.
-- Rail, logo row (settings and quit on the right), tab bar.
-- Returns the y at which content may start. Its vertical arithmetic is
-- mirrored by headerHeight() at the bottom of this file (the short-window
-- scroll decision needs the height before anything draws) -- keep in sync.
@@ -356,7 +368,14 @@ local function buildHeader(imp, m)
by + math.floor((gear - th) / 2), PAL.yellow)
end
-- Settings gear, top-right corner.
-- The right cluster is laid out right to left -- Quit outermost, the gear
-- inboard of it -- but the two are REGISTERED gear first, because the first
-- focusable of the first frame adopts the keyboard ring and that must not be
-- the button that exits the app.
local quitX = rx - gear
rx = quitX - math.floor(6 * m.s)
-- Settings gear.
imp._gearIcon = imp._gearIcon
or love.graphics.newImage("assets/launcher/gear.png")
rx = rx - gear
@@ -381,6 +400,23 @@ local function buildHeader(imp, m)
end
end
-- Quit, top-right corner.
do
local x = quitX
Kit._audit("control", x, by, gear, gear, "quit")
local focused = Kit.focusable("quit", x, by, gear, gear)
local hot = focused or Kit.hover(x, by, gear, gear)
Theme.fill(x, by, gear, gear, hot and PAL.ink or PAL.bg, 1)
Theme.stroke(x, by, gear, gear, PAL.line,
hot and Theme.A.focus or Theme.A.hairline, 1)
local pad = math.floor(gear * 0.32)
drawCross(x + pad, by + pad, gear - 2 * pad,
hot and { 0, 0, 0, 1 } or { 1, 1, 1, 0.85 })
if Kit.press(x, by, gear, gear) or Kit._activateId == "quit" then
queueAction(imp, "quit", function() imp:_quitApp() end)
end
end
-- The self-update control lives in the FOOTER next to the BCG mark (small,
-- out of the wordmark's way -- it used to overlap the logo on a phone). It
-- still GLOWS through Kit.button when there is something to act on.
@@ -2180,7 +2216,15 @@ end
-- pinned Play block used to walk up over the cards on a short window, which
-- is unusable, and the footer simply lives below the fold until scrolled to.
local function minPanelHeight(m)
return math.floor(460 * m.s)
-- One column stacks the actions card, the slot card and the pinned Play
-- block in a single pile, so it needs more room than the side-by-side
-- layout: 460 was tuned for two columns, and on a squat one-column window
-- (a 4:3 device, a phone held upright) it left the slot card clipped
-- inert against the pinned buttons -- Kit's clip bounds hit-testing, so
-- no slot could be picked at all (#852). 660 fits the actions card, one
-- slot row with its pager and New button, and the pinned block; whatever
-- the window cannot show, the page scroll above reaches.
return math.floor((m.twoCol and 460 or 660) * m.s)
end
function LauncherView.draw(imp)
+73 -24
View File
@@ -315,27 +315,16 @@ end
-- keyboard-navigates (keyboard focus is a separate grab) but ignores the
-- mouse entirely -- issue #254 on Linux. Whether it bites is a race with how
-- long the click was held, which is why the same build picks one ROM fine and
-- then hangs the mouse on the next. So pump until no button is held, letting
-- SDL see the release and let go first; bounded, so a stuck button costs a
-- moment and never the launcher. pump() only drains OS events into LOVE's
-- queue -- it dispatches nothing -- so there is no reentry into mousepressed
-- and the release is still delivered normally on the next frame.
local function releasePointerGrab()
if not (love.mouse and love.mouse.isDown and love.event and love.event.pump
and love.timer) then
return
end
local deadline = love.timer.getTime() + 1
while love.mouse.isDown(1, 2, 3) do
love.event.pump()
if love.timer.getTime() > deadline then break end
love.timer.sleep(0.005)
end
end
-- then hangs the mouse on the next.
--
-- The release itself now lives in HostShell.releasePointerGrab, called from
-- HostShell.popen, so every host spawn inherits it and not just the three
-- pickers here. It stays a single release point on purpose: this file used
-- to run its own copy first, and each copy carries its own one-second bound,
-- so keeping both made a stuck button cost two seconds instead of one.
local function commandOutput(command)
if not Platform.canSpawnProcess() then return nil end
releasePointerGrab()
local pipe = HostShell.popen(command)
if not pipe then return nil end
local result = pipe:read("*a")
@@ -997,6 +986,25 @@ local function updaterAllowed()
return true
end
-- #835: which column the launcher opens on. `tab` starts at the --game
-- shortcut's version (LaunchOptions.pendingTab) or Red; this then prefers the
-- game play() last handed off, so relaunching lands on the game that was last
-- played instead of always Red. An explicit --game still wins, and a
-- remembered version whose cache is gone or stale is ignored, since opening a
-- column with no Play button would read as the launcher losing the import.
-- Called from new() once self.ready is filled, which is what that check needs.
function RomImporter:_applyLastVersionTab()
local okLO, LO = pcall(require, "src.core.LaunchOptions")
if okLO and LO.pendingTab then return end
local okOpt, opts = pcall(function()
return require("src.core.SaveData").loadOptions()
end)
local last = okOpt and opts and opts.lastVersion
if last and GameVersion.VERSIONS[last] and self.ready[last] then
self.tab = last
end
end
-- The launcher runs each GameVersion as an independent tab. Each dropped or
-- chosen ROM is routed to its version by SHA-1, extracted into that version's
-- own cache (Red at the root, Blue under blue/, Yellow under yellow/), so all
@@ -1128,6 +1136,7 @@ function RomImporter.new(onComplete, opts)
self.romName[version] = "pokemon_" .. info.id
.. (info.id == "yellow" and ".gbc" or ".gb")
end
self:_applyLastVersionTab()
-- Android: import a save-dir .gb/.gbc that is not yet ready (USB drop or a
-- leftover SAF pick), routed by SHA-1. Already-imported carts are skipped
@@ -2240,6 +2249,17 @@ function RomImporter:play(version)
if self.workState == "working" then return end
if not self.ready[version] then return end
self._handedOff = true
-- #835: remember the game being launched so the next launcher start opens on
-- its column (_applyLastVersionTab). It rides options.lua rather than a file
-- of its own, so portable installs and POKEPORT_IDENTITY sandboxes keep it
-- with the rest of the launcher's persisted state. A failed write only
-- costs the memory of the choice, so it must never block the boot.
pcall(function()
local SaveData = require("src.core.SaveData")
local opts = SaveData.loadOptions()
opts.lastVersion = version
SaveData.saveOptions(opts)
end)
resetPointerCursor(self)
-- The game draws with raw love.graphics from here on; drop the view's
-- element tree and canvases before the handoff.
@@ -2378,10 +2398,18 @@ function RomImporter:runActions(queue)
end
-- Clicks are polled inside FlexLove (mouse + love.touch); host-forwarded
-- mousepressed stays inert so Android's synthesized mouse path cannot
-- double-fire a tap (#553). Touch move/press/release must still reach
-- mousepressed mints no click, so Android's synthesized mouse path cannot
-- double-fire a tap (#553). It DOES hand the pointer back from the pad
-- cursor (#781): a Linux boot with a joystick present arms it (see the
-- getJoystickCount block in new()), and while it is active
-- LauncherView.update refuses to mint mouse clicks, so a real press must
-- win the pointer back even when the polled motion yield misses (X11
-- multi-monitor coords). Same contract as PadCursor.yieldToPointer for
-- the overlay hosts. Touch move/press/release must still reach
-- FlexLove.touch* or scroll containers never drag on phones.
function RomImporter:mousepressed() end
function RomImporter:mousepressed()
self._padCursorActive = false
end
function RomImporter:touchpressed(id, x, y, dx, dy, pressure)
if not self._flex then return end
@@ -2428,6 +2456,13 @@ function RomImporter:_openSettings()
if ok and model then self._settings = model end
end
-- Quit from the launcher's own X. It goes through love.event.quit so main.lua's
-- love.quit hook still runs: that is where the worker threads are shut down
-- (#339) and where a launcher close is told apart from a running game's (#785).
function RomImporter:_quitApp()
if love.event and love.event.quit then love.event.quit() end
end
function RomImporter:_closeSettings()
if self._settings then self._settings.save() end
self._settings = nil
@@ -2871,9 +2906,14 @@ end
-- Update button: when a newer release is known, confirm then install; when
-- already current, force-refresh the 6h cache and report / offer update.
function RomImporter:_modGithubAction(id, action)
if not Platform.networkValidated() then
-- canFetchRemote, not networkValidated: the self-updater's gate used to
-- stand in for this one, which cost Xbox the whole mod catalog rather than
-- just the self-update it actually cannot do (#876). Say what still works
-- while we are here, since the native picker is live on every platform that
-- lands in this branch.
if not Platform.canFetchRemote() then
self.modNotice = { ok = false,
text = "Remote mod download is unavailable on this platform." }
text = "Remote mod download is unavailable on this platform. Install a mod .zip from storage instead." }
return
end
local ModUpdate = require("src.mods.ModUpdate")
@@ -3177,9 +3217,18 @@ end
-- never ran. The fetch now starts here and completes across later frames in
-- _pumpFindFetch; the loader overlay is up for the whole flight.
function RomImporter:_refreshFind(force)
if not Platform.networkValidated() then
-- The notice is the fix, not the gate (#876). This branch used to return an
-- empty listing silently, and because the player had by then added a source,
-- the panel skipped its "No mod index added" card and rendered the merged
-- listing empty state instead: a valid feed reported as "This index lists no
-- mods yet." Every other failure on this panel surfaces through findNotice,
-- and this one has to as well, or adding an index looks like it worked and
-- the index looks empty.
if not Platform.canFetchRemote() then
self.findLoaded = true
self.findIndex = { mods = {}, categories = {} }
self.findNotice = { ok = false,
text = "Mod indexes cannot be fetched on this platform. Install a mod .zip from storage instead." }
return
end
local ModIndex = require("src.mods.ModIndex")