fix(nx-mods): skip MTP AppleDouble zips and mount archives in memory

Mac OpenMTP leaves ._*.zip sidecars that fail PhysFS mount and hide a good install; prefer FileData mount on Horizon and keep success notices when a sibling fails.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Andrew Quenehen
2026-08-01 05:53:48 -03:00
parent 2223c31e93
commit 3aefe41ea1
4 changed files with 86 additions and 23 deletions
+15 -8
View File
@@ -401,10 +401,14 @@ end
local function listZipPaths(dir)
local paths = {}
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name)
if name:lower():match("%.zip$")
and love.filesystem.getInfo(path, "file") then
paths[#paths + 1] = path
-- Skip AppleDouble / hidden junk from Mac MTP (._foo.zip ends in .zip
-- but is not a PhysFS archive — mount fails with "could not be opened").
if name:sub(1, 1) ~= "." then
local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name)
if name:lower():match("%.zip$")
and love.filesystem.getInfo(path, "file") then
paths[#paths + 1] = path
end
end
end
return paths
@@ -441,20 +445,23 @@ function RomImporter:rescanModsAction()
return
end
local anyOk = false
local lastOk = nil
local lastFail = nil
for _, path in ipairs(candidates) do
-- Reuse _installMod carefully: it must not remove the inbox source.
self:_installMod(path)
if self.modNotice and self.modNotice.ok then
anyOk = true
lastOk = self.modNotice
else
lastFail = self.modNotice
end
end
if lastFail and not anyOk then
self.modNotice = lastFail
elseif lastFail and anyOk then
-- Mixed: keep failure visible after successes refreshed the list.
-- Prefer success when at least one zip installed (a leftover MTP
-- AppleDouble / corrupt sibling must not hide a good install).
if anyOk then
self.modNotice = lastOk
elseif lastFail then
self.modNotice = lastFail
end
end
+56 -15
View File
@@ -267,10 +267,18 @@ end
-- ------- install (love.filesystem)
-- Read a .zip source into bytes. A string is an external absolute path (like
-- a chosen ROM) read with io.*, falling back to a save-dir-relative
-- love.filesystem read; a love DroppedFile is opened the way RomImporter
-- ingests dropped ROMs.
-- Read a .zip source into bytes. Save-dir-relative paths (inbox /
-- picked_mod.zip) prefer love.filesystem so NX/Android never hit a cwd-relative
-- io.open that can see a different file than PhysFS. Absolute host paths
-- (desktop picker) still use io.*. DroppedFile matches RomImporter ROM drops.
local function isHostAbsolutePath(path)
return type(path) == "string" and (
path:match("^/")
or path:match("^%a:[/\\]")
or path:match("^[Ss][Dd][Mm][Cc]:")
)
end
local function readArchive(source)
local t = type(source)
if (t == "userdata" or t == "table") and type(source.open) == "function" then
@@ -282,6 +290,10 @@ local function readArchive(source)
return data
end
if t == "string" then
if not isHostAbsolutePath(source) and love and love.filesystem then
local data = love.filesystem.read(source)
if data then return data end
end
local f = io.open(source, "rb")
if f then
local data = f:read("*a")
@@ -298,6 +310,12 @@ local function readArchive(source)
return nil, "unsupported archive source"
end
-- Local PK\3\4 / empty-file check before mount (corrupt MTP / AppleDouble).
local function zipLooksValid(data)
if type(data) ~= "string" or #data < 4 then return false end
return data:sub(1, 2) == "PK"
end
-- Shallow listing of a mounted archive shaped for locateRoot: files by name,
-- and for each top-level directory a "<dir>/manifest.json" marker only when it
-- actually holds one (so a lone folder with no manifest still reads as empty).
@@ -495,21 +513,44 @@ function LauncherMods._installZipInner(source, opts)
local fs = love.filesystem
local data, readErr = readArchive(source)
if not data then return nil, readErr end
-- stage into a save-dir temp so mount can reach it
local tmp = ("mod_import_%d_%d.zip"):format(os.time(), math.random(0, 999999))
local ok, writeErr = fs.write(tmp, data)
if not ok then
return nil, "could not stage the .zip: " .. tostring(writeErr)
if not zipLooksValid(data) then
local label = type(source) == "string" and (source:match("[^/\\]+$") or source)
or "archive"
return nil, "not a zip file: " .. tostring(label)
.. " (need a real .zip; skip Mac ._ files from MTP)"
end
-- Prefer in-memory mount (PHYSFS_mountMemory via FileData). Avoids Horizon's
-- "file already open" failure when write-then-mount reopens a save-dir zip.
local mount = "mod_import_mount"
if not fs.mount(tmp, mount) then
fs.remove(tmp)
return nil, "that .zip could not be opened"
local tmp = nil
local mountKey = nil
local mounted = false
if fs.newFileData then
local archiveName = ("mod_import_%d_%d.zip"):format(
os.time(), math.random(0, 999999))
local okFd, fd = pcall(fs.newFileData, data, archiveName)
if okFd and fd and fs.mount(fd, mount) then
mounted = true
mountKey = fd
end
end
if not mounted then
-- Fallback: stage into a save-dir temp so path-mount can reach it.
tmp = ("mod_import_%d_%d.zip"):format(os.time(), math.random(0, 999999))
local ok, writeErr = fs.write(tmp, data)
if not ok then
return nil, "could not stage the .zip: " .. tostring(writeErr)
end
if not fs.mount(tmp, mount) then
fs.remove(tmp)
return nil, "that .zip could not be opened"
end
mountKey = tmp
end
local function cleanup()
pcall(fs.unmount, tmp)
fs.remove(tmp)
pcall(fs.unmount, mountKey)
if tmp then fs.remove(tmp) end
end
local prefix, rootErr = LauncherMods.locateRoot(topLevelPaths(mount))