mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-20 04:31:09 +02:00
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:
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user