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 "