mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-09-28 06:49:29 -04:00
af47e19e1a
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.
246 lines
11 KiB
Lua
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
|