349 lines
12 KiB
Lua
349 lines
12 KiB
Lua
-- DESCRIPTION: Install apps from public Xteink repositories
|
|
|
|
local ROOT = "/plugins/AppStore"
|
|
local REPOSITORIES_PATH = ROOT .. "/repositories.txt"
|
|
local DEFAULT_REPOSITORIES_PATH = ROOT .. "/repositories.default.txt"
|
|
local CATALOG_PATH = ROOT .. "/.catalog.txt"
|
|
local MAX_CATALOG_BYTES = 32768
|
|
local MAX_MANIFEST_BYTES = 16384
|
|
local MAX_APP_BYTES = 16 * 1024 * 1024
|
|
local MAX_FILE_BYTES = 8 * 1024 * 1024
|
|
local MAX_FILES = 64
|
|
|
|
local screen = "repositories"
|
|
local repositories = {}
|
|
local apps = {}
|
|
local selected = 1
|
|
local message = ""
|
|
local pendingRepository = nil
|
|
local needsDraw = true
|
|
|
|
local function removeFile(path)
|
|
if fs.exists(path) then fs.remove(path) end
|
|
end
|
|
|
|
local function clearTree(path)
|
|
if not fs.exists(path) then return true end
|
|
local ok, err = fs.removeTree(path)
|
|
if not ok or fs.exists(path) then return nil, err or "Could not remove " .. path end
|
|
return true
|
|
end
|
|
|
|
local function validRelativePath(path)
|
|
if not path or path == "" or path:sub(1, 1) == "/" or path:find("\\", 1, true) then return false end
|
|
if not path:match("^[%w%._/%-]+$") then return false end
|
|
for component in path:gmatch("[^/]+") do
|
|
if component == "." or component == ".." then return false end
|
|
end
|
|
return not path:find("//", 1, true) and path:sub(-1) ~= "/"
|
|
end
|
|
|
|
local function validHash(value)
|
|
return value and #value == 64 and value:match("^[0-9a-fA-F]+$") ~= nil
|
|
end
|
|
|
|
local function readLines(path, expectedHeader, onLine)
|
|
local offset = 0
|
|
local first = true
|
|
while true do
|
|
local line, nextOffset = fs.readLineAt(path, offset)
|
|
if not line then
|
|
if nextOffset then return nil, "Line exceeds 192 bytes" end
|
|
break
|
|
end
|
|
offset = nextOffset
|
|
if first then
|
|
first = false
|
|
if line ~= expectedHeader then return nil, "Unsupported format" end
|
|
elseif line ~= "" and line:sub(1, 1) ~= "#" then
|
|
local ok, err = onLine(line)
|
|
if not ok then return nil, err end
|
|
end
|
|
end
|
|
if first then return nil, "Empty file" end
|
|
return true
|
|
end
|
|
|
|
local function loadRepositories()
|
|
if not fs.exists(REPOSITORIES_PATH) then
|
|
local defaults = fs.readFile(DEFAULT_REPOSITORIES_PATH)
|
|
if not defaults or not fs.writeFile(REPOSITORIES_PATH, defaults) then
|
|
return nil, "Missing repositories.txt"
|
|
end
|
|
end
|
|
|
|
local result = {}
|
|
local ok, err = readLines(REPOSITORIES_PATH, "XTEINK-REPOSITORIES|1", function(line)
|
|
local name, url = line:match("^([^|]+)|(https://[^/|]+/[^|]+)$")
|
|
if not name or #name > 48 or #url > 160 or url:find("[?#]") then
|
|
return nil, "Invalid repository entry"
|
|
end
|
|
if #result >= 8 then return nil, "Too many repositories" end
|
|
result[#result + 1] = { name = name, url = url }
|
|
return true
|
|
end)
|
|
if not ok then return nil, err end
|
|
if #result == 0 then return nil, "No repositories configured" end
|
|
repositories = result
|
|
return true
|
|
end
|
|
|
|
local function renderStatus(text)
|
|
gui.clear()
|
|
gui.drawCenteredText(FONT_UI_12, math.floor(gui.height() / 2), text, COLOR_BLACK, STYLE_BOLD)
|
|
gui.refresh(REFRESH_HALF)
|
|
end
|
|
|
|
local function download(url, destination, maxBytes, expectedSize, hash)
|
|
removeFile(destination)
|
|
return net.download(url, destination, {
|
|
maxBytes = maxBytes,
|
|
expectedSize = expectedSize,
|
|
sha256 = hash
|
|
})
|
|
end
|
|
|
|
local function loadCatalog(repository)
|
|
apps = {}
|
|
collectgarbage("collect")
|
|
renderStatus("Downloading catalog...")
|
|
local bytes, err = download(repository.url, CATALOG_PATH, MAX_CATALOG_BYTES)
|
|
if not bytes then return nil, err end
|
|
|
|
local baseUrl = repository.url:match("^(.*)/[^/]+$")
|
|
if not baseUrl then return nil, "Invalid catalog URL" end
|
|
|
|
local result = {}
|
|
local ok
|
|
ok, err = readLines(CATALOG_PATH, "XTEINK-CATALOG|1", function(line)
|
|
local id, description, manifestPath, sizeText, hash =
|
|
line:match("^([%w_-]+)|([^|]+)|([^|]+)|(%d+)|([0-9a-fA-F]+)$")
|
|
local size = tonumber(sizeText)
|
|
if not id or #id > 48 or #description > 80 or not validRelativePath(manifestPath) or
|
|
not size or size < 1 or size > MAX_MANIFEST_BYTES or not validHash(hash) then
|
|
return nil, "Invalid catalog entry"
|
|
end
|
|
if #result >= 64 then return nil, "Too many apps" end
|
|
result[#result + 1] = {
|
|
id = id,
|
|
description = description,
|
|
manifestPath = manifestPath,
|
|
manifestSize = size,
|
|
manifestHash = hash:lower(),
|
|
baseUrl = baseUrl
|
|
}
|
|
return true
|
|
end)
|
|
removeFile(CATALOG_PATH)
|
|
if not ok then return nil, err end
|
|
if #result == 0 then return nil, "Repository has no apps" end
|
|
apps = result
|
|
return true
|
|
end
|
|
|
|
local function parseManifest(path)
|
|
local files = {}
|
|
local seenFiles = {}
|
|
local seenDirectories = {}
|
|
local total = 0
|
|
local hasMain = false
|
|
local ok, err = readLines(path, "XTEINK-MANIFEST|1", function(line)
|
|
local relative, sizeText, hash = line:match("^([^|]+)|(%d+)|([0-9a-fA-F]+)$")
|
|
local size = tonumber(sizeText)
|
|
local lower = relative and relative:lower()
|
|
if not validRelativePath(relative) or lower == ".manifest.txt" or not size or size < 1 or
|
|
size > MAX_FILE_BYTES or not validHash(hash) or seenFiles[lower] or seenDirectories[lower] then
|
|
return nil, "Invalid manifest entry"
|
|
end
|
|
local parent = ""
|
|
for component in lower:gmatch("[^/]+") do
|
|
if parent ~= "" then
|
|
if seenFiles[parent] then return nil, "Manifest path collision" end
|
|
seenDirectories[parent] = true
|
|
end
|
|
parent = parent == "" and component or parent .. "/" .. component
|
|
end
|
|
if #files >= MAX_FILES or total + size > MAX_APP_BYTES then return nil, "App is too large" end
|
|
seenFiles[lower] = true
|
|
total = total + size
|
|
hasMain = hasMain or relative == "main.lua"
|
|
files[#files + 1] = { path = relative, size = size, hash = hash:lower() }
|
|
return true
|
|
end)
|
|
if not ok then return nil, err end
|
|
if not hasMain then return nil, "Manifest has no main.lua" end
|
|
return files
|
|
end
|
|
|
|
local function failInstall(stage, installError)
|
|
local ok, cleanupError = clearTree(stage)
|
|
if not ok then return nil, tostring(installError) .. "; cleanup failed: " .. tostring(cleanupError) end
|
|
return nil, installError
|
|
end
|
|
|
|
local function install(app)
|
|
local target = "/plugins/" .. app.id
|
|
if fs.exists(target) then return nil, "App is already installed" end
|
|
|
|
local stage = "/plugins/.install-" .. app.id
|
|
local ok, err = clearTree(stage)
|
|
if not ok then return nil, err end
|
|
ok, err = fs.mkdir(stage)
|
|
if not ok then return nil, err end
|
|
|
|
local manifestUrl = app.baseUrl .. "/" .. app.manifestPath
|
|
local manifestBase = manifestUrl:match("^(.*)/[^/]+$")
|
|
local manifestPath = stage .. "/.manifest.txt"
|
|
renderStatus("Downloading " .. app.id .. " manifest...")
|
|
local bytes
|
|
bytes, err = download(manifestUrl, manifestPath, MAX_MANIFEST_BYTES, app.manifestSize, app.manifestHash)
|
|
if not bytes then return failInstall(stage, err) end
|
|
|
|
local files
|
|
files, err = parseManifest(manifestPath)
|
|
if not files then return failInstall(stage, err) end
|
|
|
|
for index, file in ipairs(files) do
|
|
local parent = file.path:match("^(.*)/[^/]+$")
|
|
if parent then
|
|
ok, err = fs.mkdir(stage .. "/" .. parent)
|
|
if not ok then return failInstall(stage, err) end
|
|
end
|
|
renderStatus("Installing " .. app.id .. " " .. index .. "/" .. #files)
|
|
bytes, err = download(manifestBase .. "/" .. file.path, stage .. "/" .. file.path,
|
|
file.size, file.size, file.hash)
|
|
if not bytes then return failInstall(stage, err) end
|
|
end
|
|
ok, err = fs.remove(manifestPath)
|
|
if not ok then return failInstall(stage, err) end
|
|
|
|
ok, err = fs.rename(stage, target)
|
|
if not ok then return failInstall(stage, err) end
|
|
return true, app.id .. " installed"
|
|
end
|
|
|
|
local function showMessage(text, returnScreen)
|
|
message = text
|
|
screen = "message"
|
|
pendingRepository = returnScreen
|
|
needsDraw = true
|
|
end
|
|
|
|
local function drawList(title, items, label, description)
|
|
gui.clear()
|
|
gui.drawCenteredText(FONT_UI_12, 28, title, COLOR_BLACK, STYLE_BOLD)
|
|
local visible = math.max(1, math.floor((gui.height() - 130) / 38))
|
|
local first = math.max(1, math.min(selected - math.floor(visible / 2), #items - visible + 1))
|
|
local last = math.min(#items, first + visible - 1)
|
|
local y = 72
|
|
for index = first, last do
|
|
local prefix = index == selected and "> " or " "
|
|
gui.drawText(FONT_UI_12, 36, y, prefix .. label(items[index]), COLOR_BLACK,
|
|
index == selected and STYLE_BOLD or STYLE_NORMAL)
|
|
y = y + 38
|
|
end
|
|
if items[selected] and description then
|
|
gui.drawCenteredText(FONT_UI_10, gui.height() - 82, description(items[selected]), COLOR_BLACK, STYLE_NORMAL)
|
|
end
|
|
gui.drawButtonHints("Back", "", "Select", "")
|
|
gui.refresh(REFRESH_HALF)
|
|
end
|
|
|
|
local function render()
|
|
if screen == "repositories" then
|
|
drawList("Repositories", repositories, function(item) return item.name end)
|
|
elseif screen == "apps" then
|
|
drawList("Apps", apps, function(item) return item.id end, function(item) return item.description end)
|
|
elseif screen == "connecting" then
|
|
renderStatus("Connecting to Wi-Fi...")
|
|
else
|
|
gui.clear()
|
|
gui.drawCenteredText(FONT_UI_12, math.floor(gui.height() / 2), message:sub(1, 100), COLOR_BLACK, STYLE_BOLD)
|
|
gui.drawButtonHints("Back", "", "OK", "")
|
|
gui.refresh(REFRESH_HALF)
|
|
end
|
|
needsDraw = false
|
|
end
|
|
|
|
local function moveSelection(delta, items)
|
|
if #items == 0 then return end
|
|
selected = ((selected - 1 + delta) % #items) + 1
|
|
needsDraw = true
|
|
end
|
|
|
|
function init()
|
|
local ok, err = loadRepositories()
|
|
if not ok then
|
|
showMessage(err, "exit")
|
|
return
|
|
end
|
|
end
|
|
|
|
function draw()
|
|
if screen == "connecting" then
|
|
if input.wasPressed("back") then
|
|
net.wifiDisconnect()
|
|
pendingRepository = nil
|
|
selected = 1
|
|
screen = "repositories"
|
|
needsDraw = true
|
|
else
|
|
local status = net.wifiStatus()
|
|
if status == "connected" then
|
|
local repository = pendingRepository
|
|
pendingRepository = nil
|
|
local ok, err = loadCatalog(repository)
|
|
if ok then
|
|
selected = 1
|
|
screen = "apps"
|
|
needsDraw = true
|
|
else
|
|
showMessage(err, "repositories")
|
|
end
|
|
elseif status == "failed" then
|
|
net.wifiDisconnect()
|
|
showMessage("Wi-Fi connection failed", "repositories")
|
|
end
|
|
end
|
|
elseif input.wasPressed("up") then
|
|
moveSelection(-1, screen == "apps" and apps or repositories)
|
|
elseif input.wasPressed("down") then
|
|
moveSelection(1, screen == "apps" and apps or repositories)
|
|
elseif input.wasPressed("confirm") then
|
|
if screen == "repositories" then
|
|
pendingRepository = repositories[selected]
|
|
net.wifiConnect()
|
|
screen = "connecting"
|
|
needsDraw = true
|
|
elseif screen == "apps" then
|
|
local ok, result = install(apps[selected])
|
|
showMessage(result, "apps")
|
|
elseif screen == "message" then
|
|
if pendingRepository == "exit" then
|
|
net.wifiDisconnect()
|
|
sys.exit()
|
|
return
|
|
end
|
|
screen = pendingRepository or "repositories"
|
|
pendingRepository = nil
|
|
needsDraw = true
|
|
end
|
|
elseif input.wasPressed("back") then
|
|
if screen == "apps" then
|
|
selected = 1
|
|
screen = "repositories"
|
|
needsDraw = true
|
|
elseif screen == "message" and pendingRepository ~= "exit" then
|
|
screen = pendingRepository or "repositories"
|
|
pendingRepository = nil
|
|
needsDraw = true
|
|
else
|
|
net.wifiDisconnect()
|
|
sys.exit()
|
|
return
|
|
end
|
|
end
|
|
|
|
if needsDraw then render() end
|
|
end
|