Ctrl K
ComunidadGuías…

koreader-plugin/bookorbit.koplugin/bookorbit_updater.lua

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
--[[--
Self-update support for the BookOrbit KOReader plugin.

Compares semver strings and, when an update is confirmed by the user, downloads
the plugin zip from the BookOrbit server and atomically replaces the current
plugin directory. KOReader must be restarted for the new files to load.
]]

local Device = require("device")
local NetworkMgr = require("ui/network/manager")
local Trapper = require("ui/trapper")
local UIManager = require("ui/uimanager")
local lfs = require("libs/libkoreader-lfs")
local logger = require("logger")
local T = require("ffi/util").template
local _ = require("gettext")

local BookOrbitBookSync = require("bookorbit_book_sync")
local BookOrbitApi = require("bookorbit_api")
local BookOrbitSweep = require("bookorbit_sweep")
local Transfer = require("bookorbit_download_transfer")
-- Loaded on first use: specs load this module without KOReader's widgets.
local Toast = setmetatable({}, { __index = function(_t, key) return require("bookorbit_toast")[key] end })

local BookOrbitUpdater = {}

-- Returns true if `candidate` is strictly newer than `current` (semver, optional "v" prefix).
function BookOrbitUpdater.isNewer(candidate, current)
    local function parse(v)
        if type(v) ~= "string" then return nil end
        local a, b, c = v:match("^v?(%d+)%.(%d+)%.(%d+)")
        if not a then return nil end
        return { tonumber(a), tonumber(b), tonumber(c) }
    end
    local c = parse(candidate)
    local cur = parse(current)
    if not c or not cur then return false end
    if c[1] ~= cur[1] then return c[1] > cur[1] end
    if c[2] ~= cur[2] then return c[2] > cur[2] end
    return c[3] > cur[3]
end

-- Wraps a path in POSIX single quotes, escaping any embedded single quotes.
local function sq(path)
    return "'" .. path:gsub("'", "'\\''") .. "'"
end

-- Names of the entries directly under `dir`, excluding "." and "..".
local function childNames(dir)
    local names = {}
    local ok, iterator, dir_obj = pcall(lfs.dir, dir)
    if not ok or not iterator then return names end
    for name in iterator, dir_obj do
        if name ~= "." and name ~= ".." then
            names[#names + 1] = name
        end
    end
    return names
end

-- Extracts every entry of `zip_path` below `raw_dir` with KOReader's Archiver.
-- Returns true, or nil + error string.
local function extractWithArchiver(Archiver, zip_path, raw_dir)
    local arc = Archiver.Reader:new()
    if not arc:open(zip_path) then
        local open_err = arc.err
        arc:close()
        return nil, tostring(open_err or "could not open update archive")
    end
    for entry in arc:iterate() do
        if not arc:extractToPath(entry.path, raw_dir .. "/" .. entry.path) then
            break
        end
    end
    local err = arc.err
    arc:close()
    if err then return nil, tostring(err) end
    return true
end

-- Renames the extracted tree to `staging`, dropping the archive's single
-- top-level directory when it has one. Returns true, or nil + error string.
local function stripRootInto(raw_dir, staging)
    local names = childNames(raw_dir)
    local root = names[1]
    if #names == 1 and lfs.attributes(raw_dir .. "/" .. root, "mode") == "directory" then
        if not os.rename(raw_dir .. "/" .. root, staging) then
            return nil, "could not move extracted plugin into place"
        end
        os.execute("rm -rf " .. sq(raw_dir))
        return true
    end
    if not os.rename(raw_dir, staging) then
        return nil, "could not move extracted plugin into place"
    end
    return true
end

-- Extracts the update zip into `staging`, stripping the zip's root folder.
--
-- KOReader dropped `Device:unpackArchive` in koreader/koreader@751b4978 (July
-- 2026) after its own callers moved to the `ffi/archiver` module, so prefer
-- Archiver and keep the old helper for readers that predate the module. Both
-- are probed at runtime: neither is guaranteed to exist on a given build.
local function unpackUpdate(zip_path, staging, raw_dir)
    local has_archiver, Archiver = pcall(require, "ffi/archiver")
    if has_archiver and type(Archiver) == "table" and Archiver.Reader then
        os.execute("rm -rf " .. sq(raw_dir))
        if not lfs.mkdir(raw_dir) and lfs.attributes(raw_dir, "mode") ~= "directory" then
            return nil, "could not create update staging directory"
        end
        local ok, err = extractWithArchiver(Archiver, zip_path, raw_dir)
        if ok then
            ok, err = stripRootInto(raw_dir, staging)
        end
        if not ok then
            os.execute("rm -rf " .. sq(raw_dir))
            return nil, err
        end
        return true
    end

    if type(Device.unpackArchive) ~= "function" then
        return nil, "this KOReader build provides no archive extraction support"
    end
    if not lfs.mkdir(staging) and lfs.attributes(staging, "mode") ~= "directory" then
        return nil, "could not create update staging directory"
    end
    local ok, err = Device:unpackArchive(zip_path, staging, true)
    if not ok then
        return nil, tostring(err or "archive extraction failed")
    end
    return true
end

-- Downloads the plugin zip from the server and atomically replaces `plugin_dir`.
--
-- Strategy: extract into a staging directory, backup the current plugin dir,
-- rename the new one into place, then clean up. On any failure after the backup
-- the original is restored so the user always has a working plugin.
--
-- `api`         BookOrbitApi instance (must be logged in)
-- `plugin_dir`  Absolute path of the running plugin directory
-- `opts`        Optional { on_progress = function(received, total) }. The
--               transfer runs in a subprocess when a Trapper coroutine is
--               driving, so progress arrives through polled snapshots.
--
-- Returns true on success, or nil + error string on failure.
function BookOrbitUpdater.apply(api, plugin_dir, opts)
    opts = opts or {}
    local dir = plugin_dir:gsub("/+$", "")
    local parent_dir = dir:match("^(.*)/[^/]+$")
    local plugin_name = dir:match("([^/]+)$")

    if not parent_dir or parent_dir == "" or not plugin_name then
        return nil, "cannot determine plugin parent directory"
    end

    local tmp_zip  = parent_dir .. "/bookorbit-update.zip"
    local staging  = parent_dir .. "/" .. plugin_name .. ".update"
    local raw_dir  = parent_dir .. "/" .. plugin_name .. ".unpack"
    local backup   = parent_dir .. "/" .. plugin_name .. ".bak"

    -- Remove any leftover staging dirs from a previous failed attempt.
    os.execute("rm -rf " .. sq(staging) .. " " .. sq(raw_dir))

    Transfer.sweepStale(parent_dir)
    local ok, err = Transfer.run{
        root = parent_dir,
        destination = tmp_zip,
        generation = opts.generation or 1,
        on_progress = opts.on_progress,
        is_current = opts.is_current,
        perform = function(download_opts)
            return api:downloadPluginUpdate(tmp_zip, download_opts)
        end,
    }
    if not ok then
        return nil, tostring(err or "download failed")
    end

    -- Extract into a staging directory so a partial unpack never touches the
    -- live plugin directory. Use KOReader's archive helpers instead of the
    -- platform unzip command: unzip warning exit codes vary by platform.
    local ok_unpack, unpack_err = unpackUpdate(tmp_zip, staging, raw_dir)
    os.remove(tmp_zip)

    if not ok_unpack then
        os.execute("rm -rf " .. sq(staging))
        return nil, tostring(unpack_err or "archive extraction failed")
    end

    -- The zip must contain the plugin folder as its top-level entry. We strip
    -- that root during extraction, so the staging directory should now look
    -- like the plugin directory itself.
    if lfs.attributes(staging .. "/main.lua", "mode") ~= "file" then
        os.execute("rm -rf " .. sq(staging))
        return nil, "update zip does not contain expected plugin files: " .. plugin_name
    end

    -- Atomic-ish swap (all paths share the same filesystem):
    --   1. backup current plugin
    --   2. move new plugin into place
    --   3. clean up backup and staging
    os.execute("rm -rf " .. sq(backup))
    if not os.rename(dir, backup) then
        os.execute("rm -rf " .. sq(staging))
        return nil, "could not create plugin backup"
    end
    if not os.rename(staging, dir) then
        -- Restore backup so the plugin remains usable.
        os.rename(backup, dir)
        os.execute("rm -rf " .. sq(staging))
        return nil, "could not install updated plugin"
    end

    os.execute("rm -rf " .. sq(backup) .. " " .. sq(staging))
    return true
end

-- Update-check mixin: the menu-facing status line, the throttled automatic
-- check, the interactive check and the download-and-restart flow. Installed
-- onto the plugin controller as regular methods.

local UPDATE_CHECK_INTERVAL = 24 * 60 * 60

-- Assigned from the plugin class on install (the version literal lives in
-- main.lua, where the server's package endpoint parses it from).
local PLUGIN_VERSION

local UpdateCheck = {}

function UpdateCheck:hasKnownUpdate()
    return BookOrbitUpdater.isNewer(self.settings.update_latest_version, PLUGIN_VERSION) == true
end

function UpdateCheck:updateCheckMenuText()
    if self:hasKnownUpdate() then
        return T(_("Plugin update available: v%1 -> v%2"), PLUGIN_VERSION, self.settings.update_latest_version)
    end
    if self:isLoggedIn() then
        return T(_("Installed plugin: v%1 (Check for update)"), PLUGIN_VERSION)
    end
    return T(_("Installed plugin: v%1 (Login required)"), PLUGIN_VERSION)
end

function UpdateCheck:checkForUpdate()
    if self.requestUpdateCheck then
        self:requestUpdateCheck(true, "manual")
        return
    end
    if not self:isLoggedIn() then
        self:promptLogin()
        return
    end
    NetworkMgr:runWhenConnected(function()
        self:runInSyncCoroutine(function()
            self:doCheckForUpdate()
        end)
    end)
end

function UpdateCheck:maybeCheckForUpdate(interactive)
    if not self:isLoggedIn() or self._checking_update or self._updating then return end
    if not interactive and not NetworkMgr:isConnected() then return end
    if BookOrbitSweep.isRunning() or BookOrbitBookSync.isRunning() then return end

    local now = os.time()
    if not interactive and now - (self.settings.update_check_last_at or 0) < UPDATE_CHECK_INTERVAL then
        return
    end

    self._checking_update = true
    local body, err = self:newClient():getPluginVersion()
    self._checking_update = false

    if not body then
        if self.recordSyncError then
            self:recordSyncError("update_check", err)
        end
        if interactive then
            Toast.error(T(_("Could not check for update: %1"), tostring(err or "network error")))
        else
            logger.dbg("BookOrbit: plugin update check failed:", err)
        end
        return
    end

    self:handleUpdateVersionResponse(body, interactive, interactive or self.catalog_browser == nil)
end

function UpdateCheck:doCheckForUpdate()
    if self._checking_update or self._updating then return end
    self._checking_update = true
    local api_opts = self:apiOpts(false)
    local checking = Toast.loading(_("Checking for an update…"))
    local completed, result = Trapper:dismissableRunInSubprocess(function()
        local body, err = BookOrbitApi.new(api_opts):getPluginVersion()
        return { body = body, err = err }
    end, checking)
    checking:close()
    self._checking_update = false
    if not completed then return end
    result = result or {}
    local body, err = result.body, result.err

    if not body then
        if self.recordSyncError then
            self:recordSyncError("update_check", err)
        end
        Toast.error(T(_("Could not check for update: %1"), tostring(err or "network error")))
        return
    end

    self:handleUpdateVersionResponse(body, true, true)
end

function UpdateCheck:handleUpdateVersionResponse(body, interactive, prompt_allowed)
    local server_ver = tostring(body.serverVersion or "unknown"):gsub("^v", "")
    local plugin_latest = body.pluginVersion
    self.settings.update_check_last_at = os.time()

    if type(plugin_latest) ~= "string" or plugin_latest == "unknown" then
        G_reader_settings:flush()
        if self.recordSyncError then
            self:recordSyncError("update_check", "unsupported_server", _("latest plugin version unavailable"))
        end
        if interactive then
            Toast.error(_("Could not determine the latest plugin version from the server."))
        end
        return
    end

    self.settings.update_latest_version = plugin_latest
    G_reader_settings:flush()

    if BookOrbitUpdater.isNewer(plugin_latest, PLUGIN_VERSION) ~= true then
        if interactive then
            Toast.show(T(_("BookOrbit is up to date, v%1"), tostring(PLUGIN_VERSION):gsub("^v", "")))
        end
        return
    end

    if not prompt_allowed then
        return
    end

    if not interactive and self.settings.update_dismissed_version == plugin_latest then
        return
    end
    if not interactive then
        self.settings.update_dismissed_version = plugin_latest
        G_reader_settings:flush()
    end

    require("bookorbit_update_sheet").open(self, { current = PLUGIN_VERSION, latest = plugin_latest, server = server_ver })
end

-- sheet: the update sheet that asked for it; without one (Update from the
-- menu or Settings) a sheet opens to show the download.
function UpdateCheck:applyUpdate(new_version, sheet)
    if self._updating then
        Toast.show(_("An update is already in progress."), { icon = "hourglass" })
        return
    end
    self._updating = true
    if sheet then
        sheet:downloading()
    else
        sheet = require("bookorbit_update_sheet").open(self, { current = PLUGIN_VERSION, latest = new_version, state = "downloading" })
    end
    -- The transfer owns a background subprocess, so the whole flow needs a
    -- Trapper coroutine; the busy flag clears inside it, not when wrap returns.
    Trapper:wrap(function()
        local ok, err = pcall(function()
            self:_doApplyUpdate(new_version, sheet)
        end)
        self._updating = false
        if not ok then
            sheet:failed(tostring(err))
            error(err, 0)
        end
    end)
end

function UpdateCheck:_doApplyUpdate(new_version, sheet)
    if not self.path then
        sheet:failed(_("Cannot find where the plugin is installed."))
        return
    end

    local last_bucket = -1
    local ok, err = BookOrbitUpdater.apply(self:newClient(), self.path, {
        on_progress = function(received, total)
            local bucket
            if total and total > 0 then
                bucket = math.floor(math.min(100, math.floor(received / total * 100)) / 5)
            else
                bucket = math.floor((received or 0) / (256 * 1024))
            end
            if bucket == last_bucket then return end
            last_bucket = bucket
            sheet:progressed(received, total)
            UIManager:forceRePaint()
        end,
    })

    if not ok then
        if type(err) == "number" and err == 503 then
            sheet:failed(_("The server does not have the plugin package available."))
        else
            sheet:failed(T(_("Update failed: %1"), tostring(err or "unknown error")))
        end
        return
    end
    sheet:installed()
end

function BookOrbitUpdater.install(BookOrbit)
    PLUGIN_VERSION = BookOrbit.PLUGIN_VERSION
    for name, fn in pairs(UpdateCheck) do
        BookOrbit[name] = fn
    end
end

return BookOrbitUpdater