77d123ab1f
Also mark Wordle 'present' tiles and keys with an inset ring, since gray shades alone are hard to distinguish on e-ink.
618 lines
22 KiB
Lua
618 lines
22 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 net.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 = {}
|
|
collectgarbage("collect")
|
|
if net.wifiStatus() == "connected" then
|
|
local ok, result = install(detailApp)
|
|
net.wifiDisconnect()
|
|
finishInstall(result)
|
|
else
|
|
pendingApp = detailApp
|
|
net.wifiConnect()
|
|
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
|
|
net.wifiConnect()
|
|
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
|
|
net.wifiDisconnect()
|
|
pendingRepository = nil
|
|
if pendingApp then
|
|
pendingApp = nil
|
|
finishInstall(nil)
|
|
else
|
|
selected = 1
|
|
screen = "repositories"
|
|
needsDraw = true
|
|
end
|
|
else
|
|
local status = net.wifiStatus()
|
|
if status == "connected" then
|
|
log.info("APPSTORE WIFI_CONNECTED")
|
|
if pendingApp then
|
|
local app = pendingApp
|
|
pendingApp = nil
|
|
local ok, result = install(app)
|
|
net.wifiDisconnect()
|
|
finishInstall(result)
|
|
else
|
|
local repository = pendingRepository
|
|
pendingRepository = nil
|
|
local ok, err = loadCatalog(repository)
|
|
net.wifiDisconnect()
|
|
if ok then
|
|
selected = 1
|
|
screen = "apps"
|
|
needsDraw = true
|
|
else
|
|
showMessage(err, "repositories")
|
|
end
|
|
end
|
|
elseif status == "failed" then
|
|
log.error("APPSTORE WIFI_FAILED")
|
|
net.wifiDisconnect()
|
|
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
|
|
net.wifiDisconnect()
|
|
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
|
|
net.wifiDisconnect()
|
|
sys.exit()
|
|
return
|
|
end
|
|
end
|
|
|
|
if needsDraw then render() end
|
|
end
|