mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
f30b998568
KOReader removed Device:unpackArchive in recent builds in favor of ffi/archiver. Update BookOrbitUpdater to extract archives via ffi/archiver when present, fallback to Device:unpackArchive on older builds, and report an error instead of crashing if neither is available. Closes #827
306 lines
11 KiB
Lua
306 lines
11 KiB
Lua
-- Update extraction and the atomic plugin swap. KOReader dropped
|
|
-- `Device:unpackArchive` in July 2026, so the updater probes for the newer
|
|
-- `ffi/archiver` module first and falls back to the old helper. Neither is
|
|
-- guaranteed to exist: a build that has no extraction support at all must
|
|
-- surface an error rather than crash the reader (bookorbit/bookorbit#827).
|
|
|
|
package.path = "koreader-plugin/bookorbit.koplugin/?.lua;koreader-plugin/spec/?.lua;" .. package.path
|
|
|
|
local function assertEqual(actual, expected, label)
|
|
if actual ~= expected then
|
|
error(string.format("%s: expected %s, got %s", label, tostring(expected), tostring(actual)))
|
|
end
|
|
end
|
|
|
|
local function assertTruthy(value, label)
|
|
if not value then
|
|
error(string.format("%s: expected a truthy value, got %s", label, tostring(value)))
|
|
end
|
|
end
|
|
|
|
local function shellQuote(value)
|
|
return "'" .. value:gsub("'", "'\\''") .. "'"
|
|
end
|
|
|
|
local function dirname(path)
|
|
return path:match("^(.*)/[^/]+$")
|
|
end
|
|
|
|
local function pathMode(path)
|
|
if os.execute("test -d " .. shellQuote(path)) == 0 then return "directory" end
|
|
if os.execute("test -f " .. shellQuote(path)) == 0 then return "file" end
|
|
return nil
|
|
end
|
|
|
|
local function writeFile(path, content)
|
|
os.execute("mkdir -p " .. shellQuote(dirname(path)))
|
|
local file = assert(io.open(path, "wb"))
|
|
file:write(content)
|
|
file:close()
|
|
end
|
|
|
|
local function readFile(path)
|
|
local file = io.open(path, "rb")
|
|
if not file then return nil end
|
|
local content = file:read("*a")
|
|
file:close()
|
|
return content
|
|
end
|
|
|
|
-- Minimal lfs backed by the real filesystem: the updater creates and renames
|
|
-- directories itself, so a table-tracked fake would not observe its writes.
|
|
local lfs = {}
|
|
|
|
function lfs.attributes(path, attribute)
|
|
local mode = pathMode(path)
|
|
if not mode then return nil end
|
|
if attribute == "mode" then return mode end
|
|
return { mode = mode }
|
|
end
|
|
|
|
function lfs.mkdir(path)
|
|
if pathMode(path) then return nil, "File exists" end
|
|
return os.execute("mkdir " .. shellQuote(path)) == 0 or nil
|
|
end
|
|
|
|
function lfs.dir(path)
|
|
if pathMode(path) ~= "directory" then error("cannot open " .. path) end
|
|
local pipe = assert(io.popen(
|
|
"find " .. shellQuote(path) .. " -mindepth 1 -maxdepth 1 -print"))
|
|
local names = { ".", ".." }
|
|
for full_path in pipe:lines() do
|
|
names[#names + 1] = full_path:match("([^/]+)$")
|
|
end
|
|
pipe:close()
|
|
local index = 0
|
|
return function()
|
|
index = index + 1
|
|
return names[index]
|
|
end
|
|
end
|
|
|
|
local Device = {}
|
|
local Transfer = {}
|
|
|
|
package.loaded["ui/widget/confirmbox"] = {}
|
|
package.loaded["ui/widget/infomessage"] = {}
|
|
package.loaded["ui/network/manager"] = {}
|
|
package.loaded["ui/trapper"] = {}
|
|
package.loaded["ui/uimanager"] = {}
|
|
package.loaded["device"] = Device
|
|
package.loaded["libs/libkoreader-lfs"] = lfs
|
|
package.loaded["logger"] = { dbg = function() end, info = function() end, warn = function() end }
|
|
package.loaded["ffi/util"] = { template = function(text) return text end }
|
|
package.loaded["gettext"] = function(text) return text end
|
|
package.loaded["bookorbit_book_sync"] = {}
|
|
package.loaded["bookorbit_api"] = {}
|
|
package.loaded["bookorbit_sweep"] = {}
|
|
package.loaded["bookorbit_download_transfer"] = Transfer
|
|
|
|
local BookOrbitUpdater = require("bookorbit_updater")
|
|
|
|
-- Stands in for KOReader's `ffi/archiver`: walks the declared entries and
|
|
-- materializes them at the destination paths the updater asks for.
|
|
local function fakeArchiver(entries, opts)
|
|
opts = opts or {}
|
|
local Reader = {}
|
|
Reader.__index = Reader
|
|
|
|
function Reader:new()
|
|
return setmetatable({ err = nil }, Reader)
|
|
end
|
|
|
|
function Reader:open(path)
|
|
if opts.fail_open then
|
|
self.err = "cannot open archive"
|
|
return nil
|
|
end
|
|
self.opened = path
|
|
return true
|
|
end
|
|
|
|
function Reader:iterate()
|
|
local index = 0
|
|
return function()
|
|
index = index + 1
|
|
local entry = entries[index]
|
|
if entry then return { path = entry.path, mode = entry.mode } end
|
|
end
|
|
end
|
|
|
|
function Reader:extractToPath(key, dest_path)
|
|
if opts.fail_on and key == opts.fail_on then
|
|
self.err = "extraction failed for " .. key
|
|
return false
|
|
end
|
|
for _, entry in ipairs(entries) do
|
|
if entry.path == key then
|
|
if entry.mode == "directory" then
|
|
os.execute("mkdir -p " .. shellQuote(dest_path))
|
|
else
|
|
writeFile(dest_path, entry.content or "")
|
|
end
|
|
return true
|
|
end
|
|
end
|
|
self.err = "no such path"
|
|
return false
|
|
end
|
|
|
|
function Reader:close() end
|
|
|
|
return { Reader = Reader }
|
|
end
|
|
|
|
local ROOTED_ZIP = {
|
|
{ path = "bookorbit.koplugin/", mode = "directory" },
|
|
{ path = "bookorbit.koplugin/main.lua", mode = "file", content = "-- updated main\n" },
|
|
{ path = "bookorbit.koplugin/_meta.lua", mode = "file", content = "-- updated meta\n" },
|
|
}
|
|
|
|
local FLAT_ZIP = {
|
|
{ path = "main.lua", mode = "file", content = "-- updated main\n" },
|
|
{ path = "_meta.lua", mode = "file", content = "-- updated meta\n" },
|
|
}
|
|
|
|
-- Builds an installed plugin directory and the Transfer stub that "downloads"
|
|
-- into it. Returns the sandbox root and the live plugin directory.
|
|
local function setup()
|
|
local root = os.tmpname()
|
|
os.remove(root)
|
|
assert(root:match("^/tmp/") or root:match("^/var/folders/"), "unexpected temporary path")
|
|
assert(os.execute("mkdir -p " .. shellQuote(root)) == 0)
|
|
|
|
local plugin_dir = root .. "/bookorbit.koplugin"
|
|
writeFile(plugin_dir .. "/main.lua", "-- installed main\n")
|
|
writeFile(plugin_dir .. "/_meta.lua", "-- installed meta\n")
|
|
|
|
Transfer.sweepStale = function() return 0 end
|
|
Transfer.run = function(spec)
|
|
writeFile(spec.destination, "PK\003\004 fake zip payload")
|
|
return true
|
|
end
|
|
|
|
return root, plugin_dir
|
|
end
|
|
|
|
local function cleanup(root)
|
|
if root:match("^/tmp/") or root:match("^/var/folders/") then
|
|
os.execute("rm -rf " .. shellQuote(root))
|
|
end
|
|
end
|
|
|
|
local function useArchiver(archiver)
|
|
package.loaded["ffi/archiver"] = archiver
|
|
end
|
|
|
|
local function withoutArchiver()
|
|
package.loaded["ffi/archiver"] = nil
|
|
end
|
|
|
|
do -- semver comparison
|
|
assertEqual(BookOrbitUpdater.isNewer("1.4.0", "1.3.1"), true, "a newer patch line is newer")
|
|
assertEqual(BookOrbitUpdater.isNewer("v1.4.0", "1.4.0"), false, "an equal version is not newer")
|
|
assertEqual(BookOrbitUpdater.isNewer("1.3.1", "1.4.0"), false, "an older version is not newer")
|
|
assertEqual(BookOrbitUpdater.isNewer("1.10.0", "1.9.0"), true, "minor versions compare numerically")
|
|
assertEqual(BookOrbitUpdater.isNewer(nil, "1.4.0"), false, "a missing candidate is not newer")
|
|
end
|
|
|
|
do -- the Archiver path strips the zip's single root directory
|
|
local root, plugin_dir = setup()
|
|
Device.unpackArchive = nil
|
|
useArchiver(fakeArchiver(ROOTED_ZIP))
|
|
|
|
local ok, err = BookOrbitUpdater.apply({}, plugin_dir)
|
|
|
|
assertEqual(ok, true, "the update applies via Archiver")
|
|
assertEqual(err, nil, "a successful update reports no error")
|
|
assertEqual(readFile(plugin_dir .. "/main.lua"), "-- updated main\n", "the new main.lua is installed")
|
|
assertEqual(readFile(plugin_dir .. "/_meta.lua"), "-- updated meta\n", "the new _meta.lua is installed")
|
|
assertEqual(pathMode(root .. "/bookorbit.koplugin.unpack"), nil, "the raw extraction dir is removed")
|
|
assertEqual(pathMode(root .. "/bookorbit.koplugin.update"), nil, "the staging dir is removed")
|
|
assertEqual(pathMode(root .. "/bookorbit.koplugin.bak"), nil, "the backup is removed")
|
|
assertEqual(pathMode(root .. "/bookorbit-update.zip"), nil, "the downloaded zip is removed")
|
|
cleanup(root)
|
|
end
|
|
|
|
do -- a zip without a single root directory installs as-is
|
|
local root, plugin_dir = setup()
|
|
Device.unpackArchive = nil
|
|
useArchiver(fakeArchiver(FLAT_ZIP))
|
|
|
|
local ok = BookOrbitUpdater.apply({}, plugin_dir)
|
|
|
|
assertEqual(ok, true, "a rootless zip still applies")
|
|
assertEqual(readFile(plugin_dir .. "/main.lua"), "-- updated main\n", "the new main.lua is installed")
|
|
assertEqual(pathMode(root .. "/bookorbit.koplugin.unpack"), nil, "the raw extraction dir is removed")
|
|
cleanup(root)
|
|
end
|
|
|
|
do -- older readers without ffi/archiver still use Device:unpackArchive
|
|
local root, plugin_dir = setup()
|
|
withoutArchiver()
|
|
local seen = {}
|
|
Device.unpackArchive = function(_, archive, extract_to, with_stripped_root)
|
|
seen = { archive = archive, extract_to = extract_to, stripped = with_stripped_root }
|
|
writeFile(extract_to .. "/main.lua", "-- updated main\n")
|
|
return true
|
|
end
|
|
|
|
local ok = BookOrbitUpdater.apply({}, plugin_dir)
|
|
|
|
assertEqual(ok, true, "the legacy extraction path applies the update")
|
|
assertEqual(seen.archive, root .. "/bookorbit-update.zip", "the downloaded zip is unpacked")
|
|
assertEqual(seen.extract_to, root .. "/bookorbit.koplugin.update", "extraction targets the staging dir")
|
|
assertEqual(seen.stripped, true, "the legacy helper is asked to strip the archive root")
|
|
assertEqual(readFile(plugin_dir .. "/main.lua"), "-- updated main\n", "the new main.lua is installed")
|
|
cleanup(root)
|
|
end
|
|
|
|
do -- regression: no extraction support at all must not crash the reader
|
|
local root, plugin_dir = setup()
|
|
withoutArchiver()
|
|
Device.unpackArchive = nil
|
|
|
|
local called, ok, err = pcall(BookOrbitUpdater.apply, {}, plugin_dir)
|
|
|
|
assertEqual(called, true, "a reader without archive support does not raise")
|
|
assertEqual(ok, nil, "the update reports failure")
|
|
assertTruthy(err and err:find("archive extraction", 1, true), "the error names the missing support")
|
|
assertEqual(readFile(plugin_dir .. "/main.lua"), "-- installed main\n", "the live plugin is left intact")
|
|
cleanup(root)
|
|
end
|
|
|
|
do -- a failed extraction leaves the installed plugin untouched
|
|
local root, plugin_dir = setup()
|
|
Device.unpackArchive = nil
|
|
useArchiver(fakeArchiver(ROOTED_ZIP, { fail_on = "bookorbit.koplugin/_meta.lua" }))
|
|
|
|
local ok, err = BookOrbitUpdater.apply({}, plugin_dir)
|
|
|
|
assertEqual(ok, nil, "a broken archive reports failure")
|
|
assertTruthy(err, "a broken archive reports an error message")
|
|
assertEqual(readFile(plugin_dir .. "/main.lua"), "-- installed main\n", "the live plugin is left intact")
|
|
assertEqual(pathMode(root .. "/bookorbit.koplugin.unpack"), nil, "the raw extraction dir is cleaned up")
|
|
assertEqual(pathMode(root .. "/bookorbit.koplugin.update"), nil, "the staging dir is cleaned up")
|
|
cleanup(root)
|
|
end
|
|
|
|
do -- a zip missing main.lua is rejected before the swap
|
|
local root, plugin_dir = setup()
|
|
Device.unpackArchive = nil
|
|
useArchiver(fakeArchiver({
|
|
{ path = "bookorbit.koplugin/", mode = "directory" },
|
|
{ path = "bookorbit.koplugin/_meta.lua", mode = "file", content = "-- only meta\n" },
|
|
}))
|
|
|
|
local ok, err = BookOrbitUpdater.apply({}, plugin_dir)
|
|
|
|
assertEqual(ok, nil, "an incomplete zip reports failure")
|
|
assertTruthy(err and err:find("expected plugin files", 1, true), "the error explains the rejection")
|
|
assertEqual(readFile(plugin_dir .. "/main.lua"), "-- installed main\n", "the live plugin is left intact")
|
|
cleanup(root)
|
|
end
|
|
|
|
print("bookorbit_updater_test.lua: ok")
|