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
+2
View File
@@ -245,6 +245,8 @@ Community mods install from a **separate** MTP inbox (not mixed into the ROM `im
Do **not** commit third-party mod zip bytes into git. Drop the zip over MTP, rescan, enable in MODS, then Play. Do **not** commit third-party mod zip bytes into git. Drop the zip over MTP, rescan, enable in MODS, then Play.
**MTP tip (Mac):** OpenMTP/Finder often creates AppleDouble sidecars named `._Something.zip`. Those are not real archives — the launcher ignores hidden `.*` names. If install still fails with “could not be opened” / “not a zip file”, delete any `._*.zip` under `imports/mods/` and confirm the real zip starts with the `PK` magic (re-copy the release asset if unsure).
**Example zip source:** [DramaticShape VoxelMod releases](https://github.com/DramaticShape/DramaticShapeVoxelMod/releases) — download a release `.zip`, copy into `imports/mods/`, rescan, enable. **Example zip source:** [DramaticShape VoxelMod releases](https://github.com/DramaticShape/DramaticShapeVoxelMod/releases) — download a release `.zip`, copy into `imports/mods/`, rescan, enable.
## Joy-Con display chords (Select + face) ## Joy-Con display chords (Select + face)
+15 -8
View File
@@ -401,10 +401,14 @@ end
local function listZipPaths(dir) local function listZipPaths(dir)
local paths = {} local paths = {}
for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do for _, name in ipairs(love.filesystem.getDirectoryItems(dir) or {}) do
local path = (dir == "" or dir == "/") and name or (dir .. "/" .. name) -- Skip AppleDouble / hidden junk from Mac MTP (._foo.zip ends in .zip
if name:lower():match("%.zip$") -- but is not a PhysFS archive — mount fails with "could not be opened").
and love.filesystem.getInfo(path, "file") then if name:sub(1, 1) ~= "." then
paths[#paths + 1] = path 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
end end
return paths return paths
@@ -441,20 +445,23 @@ function RomImporter:rescanModsAction()
return return
end end
local anyOk = false local anyOk = false
local lastOk = nil
local lastFail = nil local lastFail = nil
for _, path in ipairs(candidates) do for _, path in ipairs(candidates) do
-- Reuse _installMod carefully: it must not remove the inbox source. -- Reuse _installMod carefully: it must not remove the inbox source.
self:_installMod(path) self:_installMod(path)
if self.modNotice and self.modNotice.ok then if self.modNotice and self.modNotice.ok then
anyOk = true anyOk = true
lastOk = self.modNotice
else else
lastFail = self.modNotice lastFail = self.modNotice
end end
end end
if lastFail and not anyOk then -- Prefer success when at least one zip installed (a leftover MTP
self.modNotice = lastFail -- AppleDouble / corrupt sibling must not hide a good install).
elseif lastFail and anyOk then if anyOk then
-- Mixed: keep failure visible after successes refreshed the list. self.modNotice = lastOk
elseif lastFail then
self.modNotice = lastFail self.modNotice = lastFail
end end
end end
+56 -15
View File
@@ -267,10 +267,18 @@ end
-- ------- install (love.filesystem) -- ------- install (love.filesystem)
-- Read a .zip source into bytes. A string is an external absolute path (like -- Read a .zip source into bytes. Save-dir-relative paths (inbox /
-- a chosen ROM) read with io.*, falling back to a save-dir-relative -- picked_mod.zip) prefer love.filesystem so NX/Android never hit a cwd-relative
-- love.filesystem read; a love DroppedFile is opened the way RomImporter -- io.open that can see a different file than PhysFS. Absolute host paths
-- ingests dropped ROMs. -- (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 function readArchive(source)
local t = type(source) local t = type(source)
if (t == "userdata" or t == "table") and type(source.open) == "function" then if (t == "userdata" or t == "table") and type(source.open) == "function" then
@@ -282,6 +290,10 @@ local function readArchive(source)
return data return data
end end
if t == "string" then 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") local f = io.open(source, "rb")
if f then if f then
local data = f:read("*a") local data = f:read("*a")
@@ -298,6 +310,12 @@ local function readArchive(source)
return nil, "unsupported archive source" return nil, "unsupported archive source"
end 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, -- 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 -- 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). -- 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 fs = love.filesystem
local data, readErr = readArchive(source) local data, readErr = readArchive(source)
if not data then return nil, readErr end if not data then return nil, readErr end
if not zipLooksValid(data) then
-- stage into a save-dir temp so mount can reach it local label = type(source) == "string" and (source:match("[^/\\]+$") or source)
local tmp = ("mod_import_%d_%d.zip"):format(os.time(), math.random(0, 999999)) or "archive"
local ok, writeErr = fs.write(tmp, data) return nil, "not a zip file: " .. tostring(label)
if not ok then .. " (need a real .zip; skip Mac ._ files from MTP)"
return nil, "could not stage the .zip: " .. tostring(writeErr)
end 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" local mount = "mod_import_mount"
if not fs.mount(tmp, mount) then local tmp = nil
fs.remove(tmp) local mountKey = nil
return nil, "that .zip could not be opened" 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 end
local function cleanup() local function cleanup()
pcall(fs.unmount, tmp) pcall(fs.unmount, mountKey)
fs.remove(tmp) if tmp then fs.remove(tmp) end
end end
local prefix, rootErr = LauncherMods.locateRoot(topLevelPaths(mount)) local prefix, rootErr = LauncherMods.locateRoot(topLevelPaths(mount))
+13
View File
@@ -197,6 +197,19 @@ check(not removed["imports/mods/a-bad.zip"], "mixed: bad zip retained")
check(not removed["imports/mods/b-good.zip"], "mixed: good zip retained") check(not removed["imports/mods/b-good.zip"], "mixed: good zip retained")
check(love.filesystem.read("imports/mods/a-bad.zip") ~= nil, "mixed bad still present") check(love.filesystem.read("imports/mods/a-bad.zip") ~= nil, "mixed bad still present")
check(love.filesystem.read("imports/mods/b-good.zip") ~= nil, "mixed good still present") check(love.filesystem.read("imports/mods/b-good.zip") ~= nil, "mixed good still present")
check(ri.modNotice and ri.modNotice.ok, "mixed prefers success notice over sibling fail")
-- Mac MTP AppleDouble (._*.zip) must not be install candidates
ri = freshImporter()
installCalls = {}
love.filesystem.write("imports/mods/._DRAMATIC_SHAPE-1.4.0.zip", "APPL")
love.filesystem.write("imports/mods/DRAMATIC_SHAPE-1.4.0.zip", "GOOD")
installBehavior["imports/mods/DRAMATIC_SHAPE-1.4.0.zip"] = { ok = true, id = "dramatic_shape" }
ri:rescanModsAction()
eq(#installCalls, 1, "AppleDouble ._*.zip is skipped")
eq(installCalls[1], "imports/mods/DRAMATIC_SHAPE-1.4.0.zip",
"only the real zip is installed")
check(ri.modNotice and ri.modNotice.ok, "AppleDouble skip still shows install success")
-- NXMOD-05: chooseMod on NX routes to inbox rescan; no HostShell/chooseZip -- NXMOD-05: chooseMod on NX routes to inbox rescan; no HostShell/chooseZip
local hostShellCalls = 0 local hostShellCalls = 0