refactor(import): separate NX capabilities from Android flags

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andrew Quenehen
2026-08-01 03:20:09 -03:00
parent c06991e03d
commit 1ec33b1374
2 changed files with 196 additions and 21 deletions
+149 -21
View File
@@ -328,6 +328,114 @@ local function commandOutput(command)
return result ~= "" and result or nil return result ~= "" and result or nil
end end
local IMPORTS_DIR = "imports"
local ROM_BYTES = 1024 * 1024
-- Strip only a validated sdmc:/ prefix for OpenMTP/DBI relative paths.
function RomImporter.mtpHintPath(saveDir)
if type(saveDir) ~= "string" then return "" end
if saveDir:sub(1, 6) == "sdmc:/" then return saveDir:sub(7) end
return saveDir
end
function RomImporter:ensureImportsDir()
local info = love.filesystem.getInfo(IMPORTS_DIR)
if info and info.type == "directory" then return true end
if info then return false end
if love.filesystem.createDirectory then
return love.filesystem.createDirectory(IMPORTS_DIR)
end
return false
end
function RomImporter:_setNxInboxNotice(version)
version = version or self.tab or "red"
local saveDir = love.filesystem.getSaveDirectory()
local rel = RomImporter.mtpHintPath(saveDir)
if rel ~= "" and rel:sub(-1) ~= "/" then rel = rel .. "/" end
self.notice = {
version = version,
status = Strings("Copy your .gb/.gbc into:"),
detail = Strings("%s/imports/\nDBI MTP → 1: SD Card/%simports/", saveDir, rel),
}
end
local function listRomPaths(dir)
local paths = {}
for _, name in ipairs(love.filesystem.getDirectoryItems(dir)) do
local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name)
if name:lower():match("%.gbc?$")
and love.filesystem.getInfo(path, "file") then
paths[#paths + 1] = path
end
end
return paths
end
function RomImporter:scanInbox(ready)
ready = ready or self.ready
local paths = {}
for _, path in ipairs(listRomPaths(IMPORTS_DIR)) do
paths[#paths + 1] = path
end
for _, path in ipairs(listRomPaths("")) do
-- Root scan is second; imports/ entries were already collected above.
paths[#paths + 1] = path
end
return paths
end
function RomImporter:rescanAction(version)
if self.workState == "working" then return end
version = version or self.tab or "red"
self.chooseVersion = version
self:ensureImportsDir()
local ready = self.ready
local candidates = self:scanInbox(ready)
local sawReadyOnly = false
for _, path in ipairs(candidates) do
local data = love.filesystem.read(path)
local displayName = path:match("[^/\\]+$") or path
if type(data) ~= "string" then
self:setError("The file could not be read: " .. displayName, version)
return
end
if #data ~= ROM_BYTES then
self:startData(data, displayName)
return
end
local romVersion = GameVersion.forSha1(sha1(data))
if not romVersion then
self:startData(data, displayName)
return
end
if ready[romVersion] then
sawReadyOnly = true
else
self:startData(data, displayName)
return
end
end
if sawReadyOnly and #candidates > 0 then
self.notice = {
version = version,
status = Strings("No new ROM found."),
detail = Strings("Already-imported dumps are ignored. Add another version or "
.. "delete the copy when finished."),
}
return
end
self:_setNxInboxNotice(version)
end
function RomImporter:_romAction(version)
if self.isNX then
if self.ready[version] then self:reimport(version)
else self:rescanAction(version) end
elseif self.ready[version] then self:reimport(version)
else self:choose(version) end
end
-- LOVE 11.5 on Android has no native file picker (love.window.showFileDialog -- LOVE 11.5 on Android has no native file picker (love.window.showFileDialog
-- is a LOVE 12 nightly-only addition) and never fires love.filedropped, so -- is a LOVE 12 nightly-only addition) and never fires love.filedropped, so
-- neither desktop path below works there. conf.lua points the Android save -- neither desktop path below works there. conf.lua points the Android save
@@ -567,8 +675,13 @@ function RomImporter.new(onComplete, opts)
-- pending-file scan plus love.system.pickFile / createFile, provided -- pending-file scan plus love.system.pickFile / createFile, provided
-- natively by the Swift GRPickerBridge (mobile/ios/native/). The flag -- natively by the Swift GRPickerBridge (mobile/ios/native/). The flag
-- keeps its historical name so every Android call site stays untouched. -- keeps its historical name so every Android call site stays untouched.
-- NX uses a separate save-directory inbox (isNX / romImportMode) and must
-- never set android or take the mobile delete-after-import path.
local mobileOS = love.system.getOS() local mobileOS = love.system.getOS()
local android = mobileOS == "Android" or mobileOS == "iOS" local isNX = Platform.isNX()
local romImportMode = Platform.romImportMode()
local mobileFileBridge = mobileOS == "Android" or mobileOS == "iOS"
local android = mobileFileBridge
local CacheFs = require("src.import.CacheFs") local CacheFs = require("src.import.CacheFs")
local self = setmetatable({ local self = setmetatable({
onComplete = onComplete, onComplete = onComplete,
@@ -576,6 +689,9 @@ function RomImporter.new(onComplete, opts)
forceImport = opts.forceImport or false, forceImport = opts.forceImport or false,
onEditSave = opts.onEditSave, onEditSave = opts.onEditSave,
onEditTouchControls = opts.onEditTouchControls, onEditTouchControls = opts.onEditTouchControls,
isNX = isNX,
romImportMode = romImportMode,
mobileFileBridge = mobileFileBridge,
android = android, android = android,
ios = mobileOS == "iOS", ios = mobileOS == "iOS",
-- One startup poll pass on both mobiles. iOS: files dropped through the -- One startup poll pass on both mobiles. iOS: files dropped through the
@@ -587,14 +703,14 @@ function RomImporter.new(onComplete, opts)
-- pick never arrives. The file is sitting in the save dir either way, so -- pick never arrives. The file is sitting in the save dir either way, so
-- boot armed and let the first poll tick consume it, rather than making the -- boot armed and let the first poll tick consume it, rather than making the
-- player tap Import a second time to trigger the scan by hand (#553). -- player tap Import a second time to trigger the scan by hand (#553).
pickPending = android or nil, pickPending = mobileFileBridge or nil,
-- Android drag: the launcher is handed no move events at all (main.lua -- Android drag: the launcher is handed no move events at all (main.lua
-- forwards neither touchmoved nor mousemoved while it is up), and its mouse -- forwards neither touchmoved nor mousemoved while it is up), and its mouse
-- emulation is what "no reliable pointer polling" below refers to. -- emulation is what "no reliable pointer polling" below refers to.
-- love.touch IS pollable, so where it exists a touch drag can be resolved -- love.touch IS pollable, so where it exists a touch drag can be resolved
-- inside draw the same way the desktop mouse is. Where it does not, every -- inside draw the same way the desktop mouse is. Where it does not, every
-- Android path stays exactly as it was: act on press, never arm. -- Android path stays exactly as it was: act on press, never arm.
touchPollable = android and love.touch ~= nil touchPollable = mobileFileBridge and love.touch ~= nil
and love.touch.getTouches ~= nil and love.touch.getPosition ~= nil, and love.touch.getTouches ~= nil and love.touch.getPosition ~= nil,
tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods" tab = "red", -- active launcher tab: "red"/"blue"/"yellow"/"mods"
logo = love.graphics.newImage("assets/logo/logo.png"), logo = love.graphics.newImage("assets/logo/logo.png"),
@@ -674,7 +790,7 @@ function RomImporter.new(onComplete, opts)
for _, version in ipairs(GameVersion.ORDER) do for _, version in ipairs(GameVersion.ORDER) do
if not self.ready[version] then needRom = true; break end if not self.ready[version] then needRom = true; break end
end end
if android and needRom then if mobileFileBridge and needRom then
local name, data = findPendingRom(self.ready) local name, data = findPendingRom(self.ready)
if name then if name then
self:startData(data, name) self:startData(data, name)
@@ -683,6 +799,9 @@ function RomImporter.new(onComplete, opts)
-- is up, so a rejected pick can outlive the focus handler (#442). -- is up, so a rejected pick can outlive the focus handler (#442).
consumePickedRomError(self) consumePickedRomError(self)
end end
elseif self.isNX and self.launcher then
self:ensureImportsDir()
self:_setNxInboxNotice()
end end
-- Mouse-wheel scroll for the save-slot / mods lists. main.lua (off limits) -- Mouse-wheel scroll for the save-slot / mods lists. main.lua (off limits)
@@ -901,10 +1020,14 @@ function RomImporter:startData(data, displayName)
and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version] and (displayName:match("[^/\\]+$") or displayName)) or self.romName[version]
-- Android: drop the consumed save-dir .gb/.gbc (picked_rom.gb or a USB copy) -- Android: drop the consumed save-dir .gb/.gbc (picked_rom.gb or a USB copy)
-- so the next Choose / focus cannot treat it as a fresh pending ROM. -- so the next Choose / focus cannot treat it as a fresh pending ROM.
if self.android and type(displayName) == "string" if self.mobileFileBridge and type(displayName) == "string"
and not displayName:find("[/\\]") then and not displayName:find("[/\\]") then
love.filesystem.remove(displayName) love.filesystem.remove(displayName)
end end
if self.isNX and type(displayName) == "string" then
self.detail = Strings("%s imported. You may delete the copy from "
.. "imports/ when finished.", displayName)
end
self.importing = nil self.importing = nil
self.workState = "complete" self.workState = "complete"
self.completeVersion = version self.completeVersion = version
@@ -1157,15 +1280,12 @@ end
function RomImporter:choose(version) function RomImporter:choose(version)
if self.workState == "working" then return end if self.workState == "working" then return end
self.chooseVersion = version or "red" self.chooseVersion = version or "red"
if Platform.isNX() then if self.isNX then
self.notice = { self:ensureImportsDir()
version = self.chooseVersion, self:_setNxInboxNotice(self.chooseVersion)
status = "Copy your .gb/.gbc into:",
detail = love.filesystem.getSaveDirectory() .. "/imports/",
}
return return
end end
if self.android then if self.mobileFileBridge or self.android then
-- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or -- Prefer a not-yet-imported .gb/.gbc already in the save dir (USB copy, or
-- a fresh SAF pick). Never reuse an already-imported cart's file -- that -- a fresh SAF pick). Never reuse an already-imported cart's file -- that
-- was the #167 failure mode (second Choose just re-extracted Red). -- was the #167 failure mode (second Choose just re-extracted Red).
@@ -1407,7 +1527,7 @@ function RomImporter:gamepadpressed(_, button)
if self.workState == "working" then return end if self.workState == "working" then return end
local version = self.tab local version = self.tab
if GameVersion.VERSIONS[version] then if GameVersion.VERSIONS[version] then
if self.ready[version] then self:play(version) else self:choose(version) end if self.ready[version] then self:play(version) else self:_romAction(version) end
end end
end end
end end
@@ -2702,8 +2822,7 @@ function RomImporter:mousepressed(x, y, button)
self:play(self.panelVersion); return self:play(self.panelVersion); return
end end
if inside(self.romButtonRect, x, y) then if inside(self.romButtonRect, x, y) then
local version = self.panelVersion self:_romAction(self.panelVersion)
if self.ready[version] then self:reimport(version) else self:choose(version) end
return return
end end
-- SAVE FILES card: Import save / Export save, and the open-folder affordance -- SAVE FILES card: Import save / Export save, and the open-folder affordance
@@ -2908,7 +3027,7 @@ function RomImporter:keypressed(key)
-- open its picker. The mods tab has no keyboard action. -- open its picker. The mods tab has no keyboard action.
local version = self.tab local version = self.tab
if GameVersion.VERSIONS[version] then if GameVersion.VERSIONS[version] then
if self.ready[version] then self:play(version) else self:choose(version) end if self.ready[version] then self:play(version) else self:_romAction(version) end
end end
end end
end end
@@ -3204,8 +3323,14 @@ function RomImporter:_drawGamePanel(version, x, y, w, h, paged)
local rightX = twoCol and (x + colW + colGap) or x local rightX = twoCol and (x + colW + colGap) or x
-- ROM card contents by state (rehomes the existing import flow) -- ROM card contents by state (rehomes the existing import flow)
local dropHint = self.android and "Copy the .gb/.gbc via USB." local dropHint
or Strings("Or drop the .gb/.gbc file here.") if self.isNX then
dropHint = Strings("Copy your .gb/.gbc into the imports folder (see path above).")
elseif self.android then
dropHint = "Copy the .gb/.gbc via USB."
else
dropHint = Strings("Or drop the .gb/.gbc file here.")
end
local accent = version == "yellow" and PAL.gold local accent = version == "yellow" and PAL.gold
or (version == "red" and PAL.red or PAL.blue) or (version == "red" and PAL.red or PAL.blue)
local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress local romState, romDetail, romBtnLabel, romBtnEnabled, romProgress
@@ -3227,11 +3352,13 @@ function RomImporter:_drawGamePanel(version, x, y, w, h, paged)
elseif erroring then elseif erroring then
romState = "Import failed" romState = "Import failed"
romDetail = self.detail or Strings("That ROM could not be imported.") romDetail = self.detail or Strings("That ROM could not be imported.")
romBtnLabel, romBtnEnabled = "Import ROM", true romBtnLabel = self.isNX and "Procurar novamente" or "Import ROM"
romBtnEnabled = true
elseif notice then elseif notice then
romState = "No ROM imported" romState = "No ROM imported"
romDetail = trim((notice.status or "") .. " " .. (notice.detail or "")) romDetail = trim((notice.status or "") .. " " .. (notice.detail or ""))
romBtnLabel, romBtnEnabled = "Import ROM", true romBtnLabel = self.isNX and "Procurar novamente" or "Import ROM"
romBtnEnabled = true
elseif self.returning[version] then elseif self.returning[version] then
romState = "Update required" romState = "Update required"
romDetail = "This build needs a few more things from your " romDetail = "This build needs a few more things from your "
@@ -3240,7 +3367,8 @@ function RomImporter:_drawGamePanel(version, x, y, w, h, paged)
else else
romState = "No ROM imported" romState = "No ROM imported"
romDetail = "The ROM is verified before any files are created. " .. dropHint romDetail = "The ROM is verified before any files are created. " .. dropHint
romBtnLabel, romBtnEnabled = "Import ROM", true romBtnLabel = self.isNX and "Procurar novamente" or "Import ROM"
romBtnEnabled = true
end end
end end
+47
View File
@@ -0,0 +1,47 @@
-- NX RomImporter must not inherit Android mobile-file-bridge semantics (SWNX-03).
package.path = "./?.lua;./?/init.lua;" .. package.path
if not _G.love then _G.love = require("tests.love_stub") end
local S = require("tests.harness").suite("rom importer NX flags")
local eq = S.eq
local check = S.check
love.system = love.system or {}
love.filesystem = love.filesystem or {}
local saved = {
getOS = love.system.getOS,
getSaveDirectory = love.filesystem.getSaveDirectory,
createDirectory = love.filesystem.createDirectory,
}
love.system.getOS = function() return "NX" end
love.filesystem.getSaveDirectory = function() return "sdmc:/switch/gen1recomp/pokemon-love2d" end
love.filesystem.createDirectory = function() return true end
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
local RomImporter = require("src.import.RomImporter")
local ri = RomImporter.new(function() end, { launcher = true })
eq(ri.isNX, true, "NX importer sets isNX")
eq(ri.android, false, "NX importer does not set android")
eq(ri.mobileFileBridge, false, "NX importer does not set mobileFileBridge")
eq(ri.romImportMode, "save-directory", "NX importer exposes save-directory mode")
check(ri.pickPending == nil, "NX importer does not arm mobile pick polling")
love.system.getOS = function() return "Android" end
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
RomImporter = require("src.import.RomImporter")
ri = RomImporter.new(function() end, { launcher = true })
eq(ri.isNX, false, "Android importer is not NX")
eq(ri.android, true, "Android importer keeps android mobile path")
eq(ri.mobileFileBridge, true, "Android importer sets mobileFileBridge")
love.system.getOS = saved.getOS
love.filesystem.getSaveDirectory = saved.getSaveDirectory
love.filesystem.createDirectory = saved.createDirectory
package.loaded["src.core.Platform"] = nil
package.loaded["src.import.RomImporter"] = nil
S.finish()