Files
gen1recomp/src/core/HostShell.lua
T
bryanthaboi af47e19e1a Rebuild the launcher and save editor on a small immediate-mode UI kit
The launcher spent ~9ms per frame building and drawing, and the Find Mods
tab could hang the window for minutes. Both had the same root cause: a
retained UI tree rebuilt every frame, and blocking curl calls made from the
draw path.

Replace the vendored FlexLove engine (28.5k lines) with src/ui/kit/ (Kit,
Theme, Layout, Loader). The kit caches Text objects and all measurement,
allocates nothing in the steady state, and draws flat. Build+draw is now
under 1ms at every window size and on every tab (POKEPORT_LAUNCHER_PROF).

Move every network call off the render thread onto a love.thread pool
(src/net/Fetch.lua): mod index fetches, per-mod release checks, find-tab
stats, thumbnails and mod installs. Mod indexes prewarm at boot so the
Find Mods tab is populated before it is opened.

Paginate every list -- mods, find, save slots, settings, release notes,
versions -- with the page size derived from the real viewport height, so a
500-mod index costs what a 10-mod one does. Scrolling is gone.

Anything that waits now raises a non-dismissable loader; per-row background
work shows an inline spinner instead. The in-app updater moves to the top
right beside the settings gear and pulses when an update is waiting.

Theme is black with white outlines, no gradients or glows, and solid
colour-coded embossed buttons with bold labels. The game tabs keep their
cartridge colours. Everything is 1.3x larger. The save editor shares the
theme, and adding an item there is now a searchable pop-up like adding a
Pokemon.

Also:
- Reset rebinds, in Settings and under Touch Controls. Rebinds are additive
  (Input:applyBindings layers them over the defaults), so there was no
  in-game way to undo one.
- Launch options: --game red [--slot N] / POKEPORT_GAME boots straight into
  a game for shortcuts and frontends, falling back to that game's tab when
  its ROM is not imported.

Fixes found while porting:
- Ellipsis and letterspacing truncated bytes, not codepoints, so a
  multi-byte mod name crashed the first frame on a Japanese index.
  Measurement no longer throws on malformed input either.
- The new font set missed UiFont's kana fallback, rendering translated
  builds as tofu.
- Fetch workers idle in Channel:demand() and LOVE waits for live threads at
  exit, so the process outlived the window; quitting mid-download also
  waited on curl's 300s ceiling. Shut the pool down in love.quit and bound
  its transfer timeouts.
- In one column the save-slot card drew below the fold, over the footer,
  with no scrollbar left to reach it.

The two FlexLove engine tests guarded a scroll manager and an auto-height
propagation bug that no longer exist; replace them with a kit suite covering
page bounds, viewport sizing and UTF-8 truncation, and retarget the NX test
to assert the dependency is gone rather than that its perf guards are set.
2026-08-04 15:17:09 -04:00

246 lines
11 KiB
Lua

-- Helpers for calling host tools (curl, zenity/kdialog, ...).
local HostShell = {}
-- Our AppRun exports LD_LIBRARY_PATH="$APPDIR/lib:..." so every subprocess we
-- spawn tries to link against the libraries we're shipping instead of the
-- system ones. We want to unset the var so that any system tools can find
-- their proper libraries. Only needed when running in an AppImage.
function HostShell.envPrefix()
if os.getenv("APPIMAGE") then
return "env -u LD_LIBRARY_PATH "
end
return ""
end
-- Windows: every host tool we shell out to (curl for the update and mod-index
-- fetches, the PowerShell ROM picker, the update downloader's `start /b`) is
-- spawned through io.popen / os.execute, which run it under cmd.exe. A
-- GUI-subsystem process owns no console, so each of those children allocates
-- its own -- one console window flashing per call, several stacking up during
-- a mod install or an update (#606). #74 fixed the same storm for the
-- per-file cache mkdir by dropping the shell entirely (src/import/CacheFs.lua);
-- the callers above genuinely need one, so we do the other half: allocate a
-- single console for ourselves, once, and hide it. A child inherits the
-- parent's console when the parent has one, so every later spawn attaches to
-- that invisible console and pops up nothing. GUI dialogs the children raise
-- (the PowerShell OpenFileDialog) are desktop windows and still appear.
--
-- Skipped when a console already exists, which is the developer case
-- (lovec.exe, what scripts/run.ps1 prefers, or t.console), so printed output
-- keeps landing in the terminal the game was launched from. POKEPORT_CONSOLE=1
-- opts out entirely and restores the old behaviour. Memoized; non-Windows and
-- FFI-less builds no-op. Called once from love.load before anything shells
-- out (main.lua).
local consoleHidden = nil
function HostShell.hideHostConsole()
if consoleHidden ~= nil then return consoleHidden end
consoleHidden = false
if os.getenv("POKEPORT_CONSOLE") == "1" then return consoleHidden end
local okFfi, ffi = pcall(require, "ffi")
if not okFfi or ffi.os ~= "Windows" then return consoleHidden end
-- kernel32 (AllocConsole/GetConsoleWindow) and user32 (ShowWindow) are
-- already loaded in any LOVE process, so ffi.C resolves both -- the same
-- assumption CacheFs makes for CreateDirectoryA.
pcall(ffi.cdef, [[
void *GetConsoleWindow(void);
int AllocConsole(void);
int ShowWindow(void *hWnd, int nCmdShow);
]])
local ok, hidden = pcall(function()
if ffi.C.GetConsoleWindow() ~= nil then return false end
if ffi.C.AllocConsole() == 0 then return false end
local hwnd = ffi.C.GetConsoleWindow()
if hwnd == nil then return false end
ffi.C.ShowWindow(hwnd, 0) -- SW_HIDE
return true
end)
consoleHidden = (ok and hidden) or false
return consoleHidden
end
-- Wraps io.popen with the AppImage env fix applied and lua errors swallowed
function HostShell.popen(command, mode)
local ok, pipe = pcall(io.popen, HostShell.envPrefix() .. command, mode or "r")
if not ok or not pipe then return nil end
return pipe
end
-- Restart the whole app. The obvious love.event.quit("restart") re-runs LÖVE's
-- boot in-process, which calls love.filesystem.init a second time -- and inside
-- an AppImage physfs is already initialized, so that second init throws
-- ("Failed to initialize filesystem: already initialized") and the relaunch
-- crashes. So on an AppImage we relaunch the executable; the fresh process's
-- Boot step mounts any downloaded update exactly as a manual relaunch would.
-- Android hits the same wall (#575): the vendored love.cpp loops runlove()
-- in-process on "restart", and PHYSFS_deinit in the old Filesystem module's
-- destructor fails ("files still open") whenever any physfs handle survives
-- lua_close, so the second PHYSFS_init throws the same "already initialized"
-- and the app dies. There we relaunch through the GameActivity.restartApp
-- JNI bridge (love.system.restartApp), which schedules our launch intent
-- and kills the process so no native state can leak into the fresh run.
-- On every other platform the in-process restart works, so keep it.
function HostShell.restart()
if not (love and love.event and love.event.quit) then return end
local osName = love.system and love.system.getOS and love.system.getOS()
if osName == "Android" then
-- restartApp kills the process on success, so a true return is never
-- observed; false means the bridge could not schedule the relaunch.
-- An older APK whose liblove predates the bridge (love.system.restartApp
-- is nil) has no crash-free in-process restart, so quit to the OS
-- cleanly and let the player relaunch by hand -- worse than restarting,
-- but better than the guaranteed crash of quit("restart") (#575).
if love.system.restartApp and love.system.restartApp() then return end
love.event.quit()
return
end
local appimage = os.getenv("APPIMAGE")
if not appimage then
love.event.quit("restart")
return
end
-- We have to restart the process with this cursed execv call to prevent the
-- PID from changing, which might cause SteamOS and other Linux launchers to
-- think the app has crashed.
local ffi = require("ffi")
pcall(ffi.cdef, [[
int execv(const char *path, char *const argv[]);
int unsetenv(const char *name);
]])
ffi.C.unsetenv("LD_LIBRARY_PATH")
local argv = ffi.new("const char *[2]", appimage, nil)
ffi.C.execv(appimage, ffi.cast("char *const *", argv))
end
-- ------- HTTP transport ----------------------------------------------------
--
-- Every remote fetch (mod index, mod releases, thumbnails) used to shell out
-- to curl, which macOS / Windows 10+ / desktop Linux all ship and Android does
-- not: adding a mod index on Android died with "curl is not available on this
-- platform" (#597). Android goes through the GameActivity.httpDownload JNI
-- bridge instead (HttpsURLConnection, using the INTERNET permission link play
-- already needs), surfaced by our vendored liblove as
-- love.system.httpDownload(url, absPath, userAgent, accept). Both transports
-- block the calling thread and deal in whole files, so callers keep exactly
-- the contract they had with curl.
-- Shell quoting for one curl argument; cmd.exe has no single-quote form.
function HostShell.quote(s)
s = tostring(s)
if love and love.system and love.system.getOS
and love.system.getOS() == "Windows" then
return '"' .. s:gsub('"', '') .. '"'
end
return "'" .. s:gsub("'", "'\\''") .. "'"
end
function HostShell.haveCurl()
local pipe = HostShell.popen("curl --version")
if not pipe then return false end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
return readOk and out ~= nil and out:find("curl", 1, true) ~= nil
end
-- An older mobile build reports nil here and falls back to the "no transport"
-- error the callers already show.
local function haveBridge()
if not (love and love.system and type(love.system.httpDownload) == "function") then
return false
end
local osName = love.system.getOS and love.system.getOS()
return osName == "Android" or osName == "iOS"
end
-- Is any transport available at all? Callers gate on this, never on curl.
function HostShell.canFetch()
return HostShell.haveCurl() or haveBridge()
end
-- Download url to an absolute host path. Returns true, or nil plus an error.
-- The curl branch deliberately ignores curl's exit code, as the download paths
-- always did: callers judge the result by the file they got.
-- `maxTime` bounds curl's total transfer seconds. It matters at QUIT, not
-- during the transfer: LOVE waits for every live love.thread before the
-- process exits (#339), and a worker sitting inside a blocking curl cannot
-- notice a quit command until curl returns. With the launcher's default 300s
-- ceiling, closing the window during a mod download hung the process for
-- minutes. Callers on the interactive fetch pool pass something short.
function HostShell.httpDownload(url, absPath, userAgent, accept, maxTime)
if type(url) ~= "string" or url == "" then return nil, "missing url" end
if type(absPath) ~= "string" or absPath == "" then return nil, "missing path" end
userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then
local cmd = ("curl -fsSL --connect-timeout 15 --max-time %d ")
:format(tonumber(maxTime) or 300)
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
end
cmd = cmd .. "-o " .. HostShell.quote(absPath) .. " " .. HostShell.quote(url)
local pipe = HostShell.popen(cmd)
if not pipe then return nil, "could not start download" end
pcall(function() pipe:read("*a") end)
pcall(function() pipe:close() end)
return true
end
if not haveBridge() then
return nil, "no network transport on this platform"
end
local ok, done = pcall(love.system.httpDownload, url, absPath, userAgent, accept)
if ok and done then return true end
return nil, "download failed"
end
-- GET returning the body. curl streams it through a pipe; the Android bridge
-- can only write a file, so there we fetch into the save directory (the only
-- writable root on Android) and read it back.
function HostShell.httpGet(url, userAgent, accept, maxTime)
if type(url) ~= "string" or url == "" then return nil, "missing url" end
userAgent = userAgent or "gen1recomp"
if HostShell.haveCurl() then
local cmd = ("curl -fsSL --connect-timeout 10 --max-time %d ")
:format(tonumber(maxTime) or 40)
.. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " "
if accept then
cmd = cmd .. "-H " .. HostShell.quote("Accept: " .. accept) .. " "
end
cmd = cmd .. HostShell.quote(url)
local pipe = HostShell.popen(cmd)
if not pipe then return nil, "could not run curl" end
local readOk, out = pcall(function() return pipe:read("*a") end)
pcall(function() pipe:close() end)
if not readOk then return nil, "fetch failed: " .. tostring(out) end
if not out or out == "" then return nil, "empty response from " .. url end
return out
end
if not haveBridge() then
return nil, "no network transport on this platform"
end
if not (love.filesystem and love.filesystem.getSaveDirectory) then
return nil, "fetch needs LOVE"
end
local dirOk, saveDir = pcall(love.filesystem.getSaveDirectory)
if not dirOk or not saveDir or saveDir == "" then
return nil, "no save directory"
end
local name = "http_fetch.tmp"
pcall(love.filesystem.remove, name)
local ok, err = HostShell.httpDownload(url, saveDir .. "/" .. name, userAgent, accept)
if not ok then return nil, err end
local readOk, body = pcall(love.filesystem.read, name)
pcall(love.filesystem.remove, name)
if not readOk or type(body) ~= "string" or body == "" then
return nil, "empty response from " .. url
end
return body
end
return HostShell