c339a6161d
Selecting an app now opens a detail window (name, state, description) with Back / Install-Update-Reinstall / Delete, where delete needs confirmation and refuses to remove AppStore itself. Install progress replaces the button hints in that window instead of a full-screen status. Catalog entries are kept as raw lines and expanded only for drawn rows, and the catalog is released before net.wifiConnect() then rebuilt from the SD cache, which is what caused OOM when bringing up Wi-Fi. Status screens use REFRESH_FAST between updates rather than REFRESH_HALF.
602 lines
22 KiB
Lua
602 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)
|
|
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)
|
|
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()
|
|
detailApp = nil
|
|
detailMessage = nil
|
|
currentRepository = repositories[selected]
|
|
pendingRepository = currentRepository
|
|
-- 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")
|
|
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)
|
|
finishInstall(result)
|
|
else
|
|
local repository = pendingRepository
|
|
pendingRepository = nil
|
|
local ok, err = loadCatalog(repository)
|
|
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 == "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
|