39afd7fac7
The apps were on four-space indent only because nothing pinned a style; .editorconfig is what lua-language-server reads on save, so the tree now matches the sibling repos. Manifests and the catalog regenerate from the reformatted sources.
725 lines
21 KiB
Lua
725 lines
21 KiB
Lua
-- DESCRIPTION: Install apps from public Xteink repositories
|
|
|
|
local ROOT = "/.apps/AppStore"
|
|
local REPOSITORIES_PATH = ROOT .. "/repositories.txt"
|
|
local DEFAULT_REPOSITORIES_PATH = ROOT .. "/repositories.default.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 CATALOG_PATTERN = "^([%w_-]+)|([^|]+)|([^|]+)|(%d+)|([0-9a-fA-F]+)$"
|
|
|
|
local screen = "repositories"
|
|
local repositories = {}
|
|
local apps = {}
|
|
local catalogBaseUrl = nil
|
|
local currentRepository = nil
|
|
local detailApp = nil
|
|
local detailMessage = nil
|
|
local selected = 1
|
|
local message = ""
|
|
local pendingRepository = nil
|
|
local pendingApp = nil
|
|
local needsDraw = true
|
|
local lastScreen = nil
|
|
|
|
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
|
|
|
|
-- Version Marker - the catalog carries no version field, so the manifest hash stands
|
|
-- in for one. An installed tree with no marker predates this and counts as stale.
|
|
local function installState(id, manifestHash)
|
|
local target = "/.apps/" .. id
|
|
if not fs.exists(target) then
|
|
return "none"
|
|
end
|
|
return fs.readFile(target .. "/.manifest.hash") == manifestHash and "current" or "update"
|
|
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,
|
|
downloadPath = ROOT .. "/.catalog-" .. (#result + 1) .. ".txt",
|
|
}
|
|
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
|
|
|
|
-- Fast Waveform For Same-Screen Edits - FAST is a differential drive that needs a
|
|
-- trusted previous frame; only a wholesale content change needs HALF's ghost clear.
|
|
local function refreshMode()
|
|
local mode = screen == lastScreen and REFRESH_FAST or REFRESH_HALF
|
|
lastScreen = screen
|
|
return mode
|
|
end
|
|
|
|
local function wrapText(fontId, text, maxWidth)
|
|
local lines = {}
|
|
local line = ""
|
|
for word in text:gmatch "%S+" do
|
|
local candidate = line == "" and word or line .. " " .. word
|
|
if line ~= "" and gui.getTextWidth(fontId, candidate) > maxWidth then
|
|
lines[#lines + 1] = line
|
|
line = word
|
|
else
|
|
line = candidate
|
|
end
|
|
end
|
|
if line ~= "" then
|
|
lines[#lines + 1] = line
|
|
end
|
|
return lines
|
|
end
|
|
|
|
local DETAIL_STATE_LABEL = { none = "Not installed", current = "Installed", update = "Update available" }
|
|
local DETAIL_ACTION_LABEL = { none = "Install", current = "Reinstall", update = "Update" }
|
|
|
|
-- Progress Lives In The Detail Window - install status replaces the button hints rather than
|
|
-- taking over the screen, so the app being installed stays visible throughout.
|
|
local function renderDetail(status)
|
|
local state = installState(detailApp.id, detailApp.manifestHash)
|
|
local margin = 40
|
|
gui.clear()
|
|
gui.drawCenteredText(FONT_UI_12, 44, detailApp.id, COLOR_BLACK, STYLE_BOLD)
|
|
gui.drawCenteredText(FONT_UI_10, 78, DETAIL_STATE_LABEL[state] or state)
|
|
gui.drawLine(margin, 100, gui.width() - margin, 100)
|
|
|
|
local y = 134
|
|
for _, line in ipairs(wrapText(FONT_UI_10, detailApp.description, gui.width() - margin * 2)) do
|
|
gui.drawText(FONT_UI_10, margin, y, line)
|
|
y = y + 28
|
|
end
|
|
|
|
-- The hint row owns the bottom 40 px, so status and result text stack upwards from above it.
|
|
if status then
|
|
gui.drawCenteredText(FONT_UI_12, gui.height() - 90, status, COLOR_BLACK, STYLE_BOLD)
|
|
else
|
|
if detailMessage then
|
|
local lines = wrapText(FONT_UI_10, detailMessage, gui.width() - margin * 2)
|
|
local y = gui.height() - 114
|
|
for index = 1, math.min(#lines, 2) do
|
|
gui.drawCenteredText(FONT_UI_10, y, lines[index])
|
|
y = y + 26
|
|
end
|
|
end
|
|
gui.drawButtonHints("Back", DETAIL_ACTION_LABEL[state] or "Install", "", state ~= "none" and "Delete" or "")
|
|
end
|
|
gui.refresh(refreshMode())
|
|
end
|
|
|
|
local function renderStatus(text)
|
|
if detailApp then
|
|
renderDetail(text)
|
|
return
|
|
end
|
|
gui.clear()
|
|
gui.drawCenteredText(FONT_UI_12, math.floor(gui.height() / 2), text, COLOR_BLACK, STYLE_BOLD)
|
|
-- Consecutive status updates only change the centred line, so FAST's differential drive is
|
|
-- valid; only the first entry from a list screen needs HALF to clear the previous frame.
|
|
gui.refresh(lastScreen == "status" and REFRESH_FAST or REFRESH_HALF)
|
|
lastScreen = "status"
|
|
end
|
|
|
|
local function download(url, destination, maxBytes, expectedSize, hash)
|
|
removeFile(destination)
|
|
collectgarbage "collect"
|
|
log.debug("APPSTORE DOWNLOAD " .. destination)
|
|
return http.download(url, destination, {
|
|
maxBytes = maxBytes,
|
|
expectedSize = expectedSize,
|
|
sha256 = hash,
|
|
})
|
|
end
|
|
|
|
-- Entries Stay As Raw Lines - expanding 64 catalog rows into tables costs ~30KB that has to
|
|
-- coexist with the Wi-Fi and TLS stacks. Re-matching the handful of visible rows is far cheaper.
|
|
local function parseCatalog(repository, path)
|
|
catalogBaseUrl = repository.url:match "^(.*)/[^/]+$"
|
|
if not catalogBaseUrl then
|
|
return nil, "Invalid catalog URL"
|
|
end
|
|
|
|
local result = {}
|
|
local ok, err = readLines(path, "XTEINK-CATALOG|1", function(line)
|
|
local id, description, manifestPath, sizeText, hash = line:match(CATALOG_PATTERN)
|
|
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] = line
|
|
return true
|
|
end)
|
|
if not ok then
|
|
return nil, err
|
|
end
|
|
if #result == 0 then
|
|
return nil, "Repository has no apps"
|
|
end
|
|
apps = result
|
|
log.info("APPSTORE CATALOG_READY " .. #apps)
|
|
return true
|
|
end
|
|
|
|
local function catalogEntry(line)
|
|
local id, description, manifestPath, sizeText, hash = line:match(CATALOG_PATTERN)
|
|
return {
|
|
id = id,
|
|
description = description,
|
|
manifestPath = manifestPath,
|
|
manifestSize = tonumber(sizeText),
|
|
manifestHash = hash:lower(),
|
|
}
|
|
end
|
|
|
|
local function loadCatalog(repository)
|
|
apps = {}
|
|
collectgarbage "collect"
|
|
|
|
renderStatus "Downloading catalog..."
|
|
removeFile(repository.downloadPath)
|
|
local bytes, err = download(repository.url, repository.downloadPath, MAX_CATALOG_BYTES)
|
|
if not bytes then
|
|
return nil, err
|
|
end
|
|
|
|
local ok
|
|
ok, err = parseCatalog(repository, repository.downloadPath)
|
|
if not ok then
|
|
removeFile(repository.downloadPath)
|
|
return nil, err
|
|
end
|
|
return true
|
|
end
|
|
|
|
-- Rehydrate From The Downloaded File - the catalog is dropped from RAM before Wi-Fi comes up, so
|
|
-- returning to the Apps list re-parses the file rather than re-fetching it. Install state is
|
|
-- derived per row at draw time, so a freshly installed app shows correctly without a refetch.
|
|
local function ensureCatalog()
|
|
if #apps > 0 then
|
|
return true
|
|
end
|
|
if not currentRepository or not fs.exists(currentRepository.downloadPath) then
|
|
return nil, "Catalog unavailable"
|
|
end
|
|
return parseCatalog(currentRepository, currentRepository.downloadPath)
|
|
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)
|
|
log.error("APPSTORE INSTALL_FAILED " .. tostring(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)
|
|
log.info("APPSTORE INSTALL_START " .. app.id)
|
|
local target = "/.apps/" .. app.id
|
|
|
|
local stage = "/.apps/.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 = catalogBaseUrl .. "/" .. 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
|
|
log.info("APPSTORE MANIFEST_READY " .. app.id .. " " .. #files)
|
|
|
|
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
|
|
if not fs.writeFile(stage .. "/.manifest.hash", app.manifestHash) then
|
|
return failInstall(stage, "Could not record app version")
|
|
end
|
|
|
|
-- Swap Via Backup - fs.rename refuses an existing destination, and clearing the old
|
|
-- tree up front would lose the installed app if the rename then failed.
|
|
local backup = "/.apps/.old-" .. app.id
|
|
local replacing = fs.exists(target)
|
|
if replacing then
|
|
ok, err = clearTree(backup)
|
|
if not ok then
|
|
return failInstall(stage, err)
|
|
end
|
|
ok, err = fs.rename(target, backup)
|
|
if not ok then
|
|
return failInstall(stage, err)
|
|
end
|
|
end
|
|
|
|
ok, err = fs.rename(stage, target)
|
|
if not ok then
|
|
if replacing then
|
|
fs.rename(backup, target)
|
|
end
|
|
return failInstall(stage, err)
|
|
end
|
|
if replacing then
|
|
clearTree(backup)
|
|
end
|
|
|
|
log.info("APPSTORE INSTALL_DONE " .. app.id)
|
|
return true, app.id .. (replacing and " updated" or " installed")
|
|
end
|
|
|
|
local function showMessage(text, returnScreen)
|
|
log.info("APPSTORE MESSAGE " .. tostring(text))
|
|
message = text
|
|
screen = "message"
|
|
pendingRepository = returnScreen
|
|
if returnScreen == "repositories" then
|
|
selected = 1
|
|
end
|
|
needsDraw = true
|
|
end
|
|
|
|
local function leaveMessage()
|
|
screen = pendingRepository or "repositories"
|
|
pendingRepository = nil
|
|
needsDraw = true
|
|
end
|
|
|
|
-- Returning To The Apps List - the catalog is dropped from RAM before every install, so the
|
|
-- list has to be rebuilt from the cached file; falling back to Repositories keeps it honest.
|
|
local function leaveDetail()
|
|
detailApp = nil
|
|
detailMessage = nil
|
|
if ensureCatalog() then
|
|
screen = "apps"
|
|
else
|
|
selected = 1
|
|
screen = "repositories"
|
|
end
|
|
needsDraw = true
|
|
end
|
|
|
|
local function drawList(title, items, label, description, rightHint)
|
|
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", "", rightHint or "")
|
|
gui.refresh(refreshMode())
|
|
end
|
|
|
|
local APP_STATE_SUFFIX = { current = " (installed)", update = " (update)" }
|
|
|
|
local function render()
|
|
if screen == "repositories" then
|
|
drawList("Repositories", repositories, function(item)
|
|
return item.name
|
|
end, nil, "Refresh")
|
|
elseif screen == "apps" then
|
|
drawList("Apps", apps, function(line)
|
|
local app = catalogEntry(line)
|
|
return app.id .. (APP_STATE_SUFFIX[installState(app.id, app.manifestHash)] or "")
|
|
end, function(line)
|
|
return catalogEntry(line).description
|
|
end)
|
|
elseif screen == "detail" then
|
|
renderDetail(nil)
|
|
elseif screen == "confirmDelete" then
|
|
gui.clear()
|
|
gui.drawCenteredText(
|
|
FONT_UI_12,
|
|
math.floor(gui.height() / 2) - 24,
|
|
"Delete " .. detailApp.id .. "?",
|
|
COLOR_BLACK,
|
|
STYLE_BOLD
|
|
)
|
|
gui.drawCenteredText(FONT_UI_10, math.floor(gui.height() / 2) + 18, "Removes the installed app from this device.")
|
|
gui.drawButtonHints("Cancel", "Delete", "", "")
|
|
gui.refresh(refreshMode())
|
|
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(refreshMode())
|
|
end
|
|
needsDraw = false
|
|
end
|
|
|
|
local function moveSelection(delta, items)
|
|
if #items == 0 then
|
|
return
|
|
end
|
|
selected = ((selected - 1 + delta) % #items) + 1
|
|
needsDraw = true
|
|
end
|
|
|
|
local function finishInstall(result)
|
|
detailMessage = result
|
|
screen = "detail"
|
|
needsDraw = true
|
|
end
|
|
|
|
local function startInstall()
|
|
detailMessage = nil
|
|
-- Shed The Catalog Before The Radio - install downloads run with the Wi-Fi and TLS stacks
|
|
-- resident, and 64 held entries are what tips this into OOM. Rebuilt from cache on the way out.
|
|
apps = {}
|
|
if wifi.status().state == "connected" then
|
|
local ok, result = install(detailApp)
|
|
wifi.disconnect()
|
|
finishInstall(result)
|
|
else
|
|
pendingApp = detailApp
|
|
wifi.connect()
|
|
screen = "connecting"
|
|
needsDraw = true
|
|
end
|
|
end
|
|
|
|
local function deleteApp()
|
|
-- Never Delete Ourselves - the running chunk is already in RAM so this would not crash, but it
|
|
-- would silently uninstall the store mid-session.
|
|
if "/.apps/" .. detailApp.id == ROOT then
|
|
finishInstall "Cannot delete the running app"
|
|
return
|
|
end
|
|
local ok, err = clearTree("/.apps/" .. detailApp.id)
|
|
log.info("APPSTORE DELETE " .. detailApp.id .. " " .. tostring(ok))
|
|
finishInstall(ok and (detailApp.id .. " deleted") or tostring(err))
|
|
end
|
|
|
|
local function openRepository(forceDownload)
|
|
detailApp = nil
|
|
detailMessage = nil
|
|
currentRepository = repositories[selected]
|
|
-- Shed The Stale Catalog Before The Radio - bringing up Wi-Fi allocates tens of KB, and the
|
|
-- previous repository's entries are dead weight from the moment we leave the list.
|
|
apps = {}
|
|
collectgarbage "collect"
|
|
|
|
-- Browse Offline - Wi-Fi and TLS cost ~70 KB that stays resident for the whole session, and
|
|
-- scrolling a list at that heap level is what aborts the firmware. Serve the cached catalog
|
|
-- and bring the radio up only for an explicit refresh or an install.
|
|
if not forceDownload and fs.exists(currentRepository.downloadPath) and ensureCatalog() then
|
|
selected = 1
|
|
screen = "apps"
|
|
needsDraw = true
|
|
return
|
|
end
|
|
|
|
pendingRepository = currentRepository
|
|
wifi.connect()
|
|
screen = "connecting"
|
|
needsDraw = true
|
|
end
|
|
|
|
function init()
|
|
local ok, err = loadRepositories()
|
|
if not ok then
|
|
showMessage(err, "exit")
|
|
return
|
|
end
|
|
log.info "APPSTORE READY"
|
|
end
|
|
|
|
function draw()
|
|
if screen == "connecting" then
|
|
if input.wasPressed "back" then
|
|
wifi.disconnect()
|
|
pendingRepository = nil
|
|
if pendingApp then
|
|
pendingApp = nil
|
|
finishInstall(nil)
|
|
else
|
|
selected = 1
|
|
screen = "repositories"
|
|
needsDraw = true
|
|
end
|
|
else
|
|
local status = wifi.status().state
|
|
if status == "connected" then
|
|
log.info "APPSTORE WIFI_CONNECTED"
|
|
if pendingApp then
|
|
local app = pendingApp
|
|
pendingApp = nil
|
|
local ok, result = install(app)
|
|
wifi.disconnect()
|
|
finishInstall(result)
|
|
else
|
|
local repository = pendingRepository
|
|
pendingRepository = nil
|
|
local ok, err = loadCatalog(repository)
|
|
wifi.disconnect()
|
|
if ok then
|
|
selected = 1
|
|
screen = "apps"
|
|
needsDraw = true
|
|
else
|
|
showMessage(err, "repositories")
|
|
end
|
|
end
|
|
elseif status == "failed" then
|
|
log.error "APPSTORE WIFI_FAILED"
|
|
wifi.disconnect()
|
|
if pendingApp then
|
|
pendingApp = nil
|
|
finishInstall "Wi-Fi connection failed"
|
|
else
|
|
showMessage("Wi-Fi connection failed", "repositories")
|
|
end
|
|
end
|
|
end
|
|
elseif input.wasPressed "up" and (screen == "apps" or screen == "repositories") then
|
|
moveSelection(-1, screen == "apps" and apps or repositories)
|
|
elseif input.wasPressed "down" and (screen == "apps" or screen == "repositories") then
|
|
moveSelection(1, screen == "apps" and apps or repositories)
|
|
elseif input.wasPressed "right" then
|
|
if screen == "repositories" then
|
|
openRepository(true)
|
|
elseif screen == "detail" and installState(detailApp.id, detailApp.manifestHash) ~= "none" then
|
|
detailMessage = nil
|
|
screen = "confirmDelete"
|
|
needsDraw = true
|
|
end
|
|
elseif input.wasPressed "confirm" then
|
|
if screen == "repositories" then
|
|
openRepository()
|
|
elseif screen == "apps" then
|
|
detailApp = catalogEntry(apps[selected])
|
|
detailMessage = nil
|
|
screen = "detail"
|
|
needsDraw = true
|
|
elseif screen == "detail" then
|
|
startInstall()
|
|
elseif screen == "confirmDelete" then
|
|
deleteApp()
|
|
elseif screen == "message" then
|
|
if pendingRepository == "exit" then
|
|
wifi.disconnect()
|
|
sys.exit()
|
|
return
|
|
end
|
|
leaveMessage()
|
|
end
|
|
elseif input.wasPressed "back" then
|
|
if screen == "apps" then
|
|
selected = 1
|
|
screen = "repositories"
|
|
needsDraw = true
|
|
elseif screen == "detail" then
|
|
leaveDetail()
|
|
elseif screen == "confirmDelete" then
|
|
screen = "detail"
|
|
needsDraw = true
|
|
elseif screen == "message" and pendingRepository ~= "exit" then
|
|
leaveMessage()
|
|
else
|
|
wifi.disconnect()
|
|
sys.exit()
|
|
return
|
|
end
|
|
end
|
|
|
|
if needsDraw then
|
|
render()
|
|
end
|
|
end
|