From 3aefe41ea18e35db12cca78145308316f9d923e2 Mon Sep 17 00:00:00 2001 From: Andrew Quenehen Date: Sat, 1 Aug 2026 05:53:48 -0300 Subject: [PATCH] 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 --- docs/switch-development.md | 2 + src/import/RomImporter.lua | 23 +++++--- src/mods/LauncherMods.lua | 71 ++++++++++++++++++----- tests/rom_importer_nx_mods_inbox_test.lua | 13 +++++ 4 files changed, 86 insertions(+), 23 deletions(-) diff --git a/docs/switch-development.md b/docs/switch-development.md index 7faf3621..b1a04d2c 100644 --- a/docs/switch-development.md +++ b/docs/switch-development.md @@ -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. +**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. ## Joy-Con display chords (Select + face) diff --git a/src/import/RomImporter.lua b/src/import/RomImporter.lua index 78322b00..1f4ae426 100644 --- a/src/import/RomImporter.lua +++ b/src/import/RomImporter.lua @@ -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 diff --git a/src/mods/LauncherMods.lua b/src/mods/LauncherMods.lua index 3a7e9062..f117be93 100644 --- a/src/mods/LauncherMods.lua +++ b/src/mods/LauncherMods.lua @@ -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 "/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)) diff --git a/tests/rom_importer_nx_mods_inbox_test.lua b/tests/rom_importer_nx_mods_inbox_test.lua index 4c8e9f38..b7b429e9 100644 --- a/tests/rom_importer_nx_mods_inbox_test.lua +++ b/tests/rom_importer_nx_mods_inbox_test.lua @@ -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(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(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 local hostShellCalls = 0