Static 'coming soon' stub authored as Markdown with JSON-frontmatter title. Loaded once on init, parsed via the same fm + md helpers as modules. Live news (Gitea-API or RSS) deferred — single-source-of- truth in the launcher repo keeps update-pace under our control.
398 lines
16 KiB
Lua
398 lines
16 KiB
Lua
local input = require("lib-core.input")
|
|
local git = require("lib-core.git")
|
|
local depf = require("lib-management.dep-fetcher")
|
|
-- fsm.lua lives in this module's dir; engine_lua_lib's searcher only
|
|
-- resolves declared lib-deps, so multi-file modules use dofile +
|
|
-- engine.module.dir_of.
|
|
local MODULE_DIR = engine.module.dir_of("lib-management.launcher")
|
|
local fsm = dofile(MODULE_DIR .. "/fsm.lua")
|
|
local manifest_loader = dofile(MODULE_DIR .. "/manifest_loader.lua")
|
|
|
|
local function require_panel(name)
|
|
return dofile(MODULE_DIR .. "/panels/" .. name .. ".lua")
|
|
end
|
|
|
|
local list_panel = require_panel("list")
|
|
local detail_panel = require_panel("detail")
|
|
local carousel = require_panel("carousel")
|
|
|
|
local STATE_LIST, STATE_QUIT, EXIT = fsm.STATE_LIST, fsm.STATE_QUIT_CONFIRM, fsm.EXIT
|
|
local state = STATE_LIST
|
|
|
|
local ctx_panels = nil -- built in init(); shared state for both panels
|
|
local quit_buttons = {} -- {yes = rect, no = rect}
|
|
local screen_w, screen_h
|
|
local gitea_base = nil -- resolved in init() from engine.config
|
|
|
|
-- CI-conditional self-exit (matches spine-prototype's pattern).
|
|
-- Interactive runs (no SPOREL_CI=1) loop until ESC or quit-confirm.
|
|
local frames = 0
|
|
local CI_FRAMES = 60
|
|
|
|
local TITLE = "Sporel Launcher"
|
|
local QUIT_PROMPT = "Sporel beenden?"
|
|
local YES_LABEL = "Ja"
|
|
local NO_LABEL = "Nein"
|
|
local EMPTY_LABEL = "Keine Module installiert."
|
|
|
|
local FONT_TITLE = 28
|
|
local FONT_BUTTON = 20
|
|
local PAD_X = 16
|
|
local PAD_Y = 8
|
|
|
|
local COLOR_BG = 0x202020FF
|
|
local COLOR_BTN = 0x404040FF
|
|
local COLOR_HOVER = 0x606060FF
|
|
local COLOR_TEXT = 0xE0E0E0FF
|
|
local COLOR_DIM = 0x000000B4
|
|
local COLOR_MODAL = 0x303030FF
|
|
|
|
local hit = engine.spatial.aabb_contains_point
|
|
|
|
-- ----- helpers -------------------------------------------------------
|
|
|
|
-- Compare semver-ish "vX.Y.Z" strings. Returns true if a > b.
|
|
local function compare_versions(a, b)
|
|
local a_maj, a_min, a_pat = a:match("v?(%d+)%.(%d+)%.(%d+)")
|
|
local b_maj, b_min, b_pat = b:match("v?(%d+)%.(%d+)%.(%d+)")
|
|
if not a_maj or not b_maj then return false end
|
|
local av = { tonumber(a_maj), tonumber(a_min), tonumber(a_pat) }
|
|
local bv = { tonumber(b_maj), tonumber(b_min), tonumber(b_pat) }
|
|
for i = 1, 3 do
|
|
if av[i] > bv[i] then return true end
|
|
if av[i] < bv[i] then return false end
|
|
end
|
|
return false
|
|
end
|
|
|
|
local function module_repo_url(module_id)
|
|
return gitea_base .. "/sporel-module-" .. module_id .. ".git"
|
|
end
|
|
|
|
-- After a successful git.checkout on a module dir, reload its manifest
|
|
-- to surface the new version in the UI. Uses dep-fetcher's pure-Lua
|
|
-- JSON decoder so we don't add a new engine.json binding for one site.
|
|
local function refresh_module_version(m)
|
|
local path = engine.install_root() .. "/modules/" .. m.id
|
|
.. "/manifest.module"
|
|
local f = io.open(path, "rb")
|
|
if not f then return end
|
|
local content = f:read("*a"); f:close()
|
|
local data = depf._json_decode(content)
|
|
if data and data.version then m.version = data.version end
|
|
end
|
|
|
|
local function start_update_check(m)
|
|
if not gitea_base then return end
|
|
local url = module_repo_url(m.id)
|
|
local handle = git.start_ls_remote_tags(url)
|
|
m.status.update_check_handle = handle
|
|
m.status.update_check_result = "pending"
|
|
m.status.update_latest_tag = nil
|
|
end
|
|
|
|
-- Result poll: success -> compare highest vX.Y.Z tag against m.version.
|
|
-- Error kinds from lib-core.git v0.2.0: "auth-required" | "not-found" |
|
|
-- "network" | "other". "not-found" → local-only marker; everything else
|
|
-- folds into a generic "fail" with the kind preserved for tooltip text.
|
|
local function consume_update_check(m)
|
|
local result = git.take_result(m.status.update_check_handle)
|
|
m.status.update_check_handle = nil
|
|
if not result or result.error then
|
|
local kind = result and result.kind or "other"
|
|
if kind == "not-found" then
|
|
m.status.update_check_result = "local-only"
|
|
else
|
|
m.status.update_check_result = "fail"
|
|
m.status.update_check_fail_kind = kind
|
|
end
|
|
return
|
|
end
|
|
local latest = nil
|
|
for _, tag in ipairs(result.tags or {}) do
|
|
if tag:match("^v%d+%.%d+%.%d+$") then
|
|
if not latest or compare_versions(tag, latest) then
|
|
latest = tag
|
|
end
|
|
end
|
|
end
|
|
if latest and compare_versions(latest, "v" .. m.version) then
|
|
m.status.update_check_result = "available"
|
|
m.status.update_latest_tag = latest
|
|
else
|
|
m.status.update_check_result = "no-update"
|
|
end
|
|
end
|
|
|
|
-- Composes a per-dep status list keyed by lib_id from the dep-fetcher
|
|
-- result's `errors` and `warnings` arrays. Each input dep entry is
|
|
-- `{id, version}` (from manifest.module::deps[]); each output entry
|
|
-- adds `status = "ok" | "warn" | "error"`. Load-order preserved
|
|
-- (L-Q6: no re-sort).
|
|
local function compose_dep_status_list(result, module_deps)
|
|
local out = {}
|
|
local err_ids, warn_ids = {}, {}
|
|
for _, e in ipairs(result.errors or {}) do
|
|
if e.lib_id then err_ids[e.lib_id] = true end
|
|
end
|
|
for _, w in ipairs(result.warnings or {}) do
|
|
if w.lib_id then warn_ids[w.lib_id] = true end
|
|
end
|
|
for _, c in ipairs(result.conflicts or {}) do
|
|
if c.lib_id then err_ids[c.lib_id] = true end
|
|
end
|
|
for _, d in ipairs(module_deps) do
|
|
local status = "ok"
|
|
if err_ids[d.id] then status = "error"
|
|
elseif warn_ids[d.id] then status = "warn"
|
|
end
|
|
out[#out + 1] = { id = d.id, version = d.version, status = status }
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- Derives a roll-up kind ("ok" | "warn" | "error") from the dep-fetcher
|
|
-- result. Drives the list-panel's bg-tint cue via ctx.list_tint.
|
|
local function derive_result_kind(result)
|
|
if not result.ok then return "error" end
|
|
if (result.warnings and #result.warnings > 0) then return "warn" end
|
|
return "ok"
|
|
end
|
|
|
|
-- Lazy: only re-check dep-fetcher when the consumer flow asks for it
|
|
-- (init + after-update). Heavy-lift in init() is acceptable per the
|
|
-- plan; an async dep-check is future work.
|
|
local function run_dep_check(m)
|
|
local ok, result = pcall(depf.ensure_for_module, m.id)
|
|
if not (ok and type(result) == "table") then
|
|
result = {
|
|
ok = false, conflicts = {}, warnings = {},
|
|
errors = {{ lib_id = "<launcher>", kind = "internal",
|
|
message = tostring(result) }},
|
|
closure = {},
|
|
}
|
|
end
|
|
result.deps = compose_dep_status_list(result, m._raw_deps or {})
|
|
result.kind = derive_result_kind(result)
|
|
m.status.dep_check_result = result
|
|
end
|
|
|
|
-- ----- lifecycle -----------------------------------------------------
|
|
|
|
function init(ctx)
|
|
screen_w, screen_h = engine.window.size()
|
|
gitea_base = engine.config("gitea_base")
|
|
or "https://git.davoryn.de/sporel"
|
|
|
|
-- Slice 7.1: override lib-core.git's token (defaulted from
|
|
-- SPOREL_GITEA_TOKEN env at lib_init) with the engine.json layer
|
|
-- value if the user-data config sets gitea_token. Allows users to
|
|
-- persist their Gitea PAT in %APPDATA%/Sporel/engine.json instead
|
|
-- of carrying an env var around.
|
|
local cfg_token = engine.config("gitea_token")
|
|
if cfg_token and cfg_token ~= "" and type(git.set_token) == "function" then
|
|
git.set_token(cfg_token)
|
|
end
|
|
|
|
ctx_panels = {
|
|
-- Persistent UI context, threaded through the panels.
|
|
module_entries = {}, -- filled below from engine.module.list
|
|
news_entry = { title = "News", body_path = "news.md" },
|
|
selected_entry = nil,
|
|
scroll_y = 0,
|
|
list_tint = nil, -- nil | "warn" | "error" — set in L.4
|
|
list_panel_rect = nil, -- set below from window size
|
|
detail_panel_rect = nil,
|
|
bg_texture = nil, -- L.10 loads it
|
|
default_teaser_texture = nil, -- L.10 loads it
|
|
t = function(k) return k end, -- L.8 swaps in real t()
|
|
modal_open = nil, -- L.7 sets to "quit" | "conflict"
|
|
carousel_state = {}, -- per-module-id state, L.5 populates
|
|
manifest_cache = {}, -- L.3 populates
|
|
launcher_dir = MODULE_DIR, -- L.4: detail.lua dofiles markdown.lua via this
|
|
}
|
|
|
|
-- Window split: 1/3 list, 2/3 detail. Margin 12px between panels.
|
|
local SW, SH = screen_w, screen_h
|
|
ctx_panels.list_panel_rect = { x = 0, y = 0, w = SW / 3, h = SH }
|
|
ctx_panels.detail_panel_rect = { x = SW / 3 + 12, y = 0, w = SW * 2/3 - 12, h = SH }
|
|
|
|
local raw_list = engine.module.list() or {}
|
|
for i, m in ipairs(raw_list) do
|
|
ctx_panels.module_entries[i] = {
|
|
id = m.id,
|
|
version = m.version,
|
|
name = m.name or m.id,
|
|
summary = nil, -- L.3 fills from manifest.launcher.md
|
|
rect = nil,
|
|
status = {
|
|
update_check_handle = nil,
|
|
update_check_result = nil,
|
|
update_check_fail_kind = nil,
|
|
update_latest_tag = nil,
|
|
dep_check_result = nil,
|
|
spinner_rect = nil, warn_rect = nil,
|
|
badge_rect = nil, error_rect = nil,
|
|
local_rect = nil,
|
|
},
|
|
}
|
|
end
|
|
|
|
-- L.3: load + cache manifest.launcher.md for each module. Missing
|
|
-- files degrade gracefully (meta._missing=true) so the list-panel
|
|
-- falls back to name/id.
|
|
for _, entry in ipairs(ctx_panels.module_entries) do
|
|
local meta, blocks = manifest_loader.load_for(entry.id)
|
|
ctx_panels.manifest_cache[entry.id] = { meta = meta, blocks = blocks }
|
|
entry.summary = meta.summary
|
|
|
|
-- L.4: also surface manifest.module::deps[] verbatim so the
|
|
-- detail-panel can render per-dep status glyphs in load order
|
|
-- (no re-sort, per L-Q6).
|
|
local raw, _ = engine.module.read_file(entry.id, "manifest.module")
|
|
if raw then
|
|
local data = depf._json_decode(raw)
|
|
if data and data.deps then entry._raw_deps = data.deps end
|
|
end
|
|
end
|
|
|
|
-- L.5: per-module carousel state. Reads teaser_images +
|
|
-- carousel_interval_seconds from the cached manifest.launcher.md
|
|
-- meta. Modules without teasers still get an empty-state for the
|
|
-- detail panel to render the fallback texture.
|
|
ctx_panels.carousel_mod = carousel
|
|
for _, entry in ipairs(ctx_panels.module_entries) do
|
|
local mc = ctx_panels.manifest_cache[entry.id]
|
|
local paths = (mc and mc.meta and mc.meta.teaser_images) or {}
|
|
local interval = (mc and mc.meta and mc.meta.carousel_interval_seconds) or 5.0
|
|
ctx_panels.carousel_state[entry.id] = carousel.new_state(entry.id, paths, interval)
|
|
end
|
|
|
|
-- L.6: load + parse news.md once. The detail-panel's news branch
|
|
-- reads ctx.news_body_blocks; nil-safe so a missing file still
|
|
-- shows the "News" entry with an empty body.
|
|
do
|
|
local news_path = MODULE_DIR .. "/news.md"
|
|
local f = io.open(news_path, "rb")
|
|
if f then
|
|
local content = f:read("*a")
|
|
f:close()
|
|
local fm = dofile(MODULE_DIR .. "/frontmatter.lua")
|
|
local md = dofile(MODULE_DIR .. "/markdown.lua")
|
|
local meta, body = fm.parse(content)
|
|
if meta then
|
|
ctx_panels.news_entry.title = meta.title or "News"
|
|
ctx_panels.news_body_blocks = md.parse(body)
|
|
end
|
|
end
|
|
end
|
|
|
|
local wy, hy = engine.render.measure_text(YES_LABEL, FONT_BUTTON)
|
|
local wn, hn = engine.render.measure_text(NO_LABEL, FONT_BUTTON)
|
|
local qbtn_w = math.max(wy, wn) + 2 * PAD_X
|
|
local qbtn_h = math.max(hy, hn) + 2 * PAD_Y
|
|
local total_w = 2 * qbtn_w + 24
|
|
quit_buttons.yes = { x = (screen_w - total_w) / 2, y = screen_h / 2 + 24, w = qbtn_w, h = qbtn_h }
|
|
quit_buttons.no = { x = (screen_w - total_w) / 2 + qbtn_w + 24, y = screen_h / 2 + 24, w = qbtn_w, h = qbtn_h }
|
|
|
|
input.bind("ui_click", { "mouse_left" })
|
|
input.bind("ui_back", { "escape" })
|
|
input.bind("ui_confirm", { "enter" })
|
|
|
|
-- Kick off async update-checks (one ls-remote per module). Network
|
|
-- calls; gated off in CI to keep milestone-check offline.
|
|
if os.getenv("SPOREL_CI") ~= "1" then
|
|
for _, m in ipairs(ctx_panels.module_entries) do start_update_check(m) end
|
|
end
|
|
|
|
-- Pre-emptive dep-check per module. Sync today (plan §7.6 future
|
|
-- work: async). In a packaged install with closures already
|
|
-- present, this is filesystem-only and fast; the slow path
|
|
-- (missing libs → clone) only fires on first launch / new module.
|
|
for _, m in ipairs(ctx_panels.module_entries) do run_dep_check(m) end
|
|
|
|
engine.print(string.format("launcher: init ok, modules=%d",
|
|
#ctx_panels.module_entries))
|
|
end
|
|
|
|
function update(ctx, dt)
|
|
frames = frames + 1
|
|
if os.getenv("SPOREL_CI") == "1" and frames >= CI_FRAMES then
|
|
engine.exit(0)
|
|
return
|
|
end
|
|
|
|
-- Poll async update-check handles.
|
|
for _, m in ipairs(ctx_panels.module_entries) do
|
|
local h = m.status.update_check_handle
|
|
if h and git.is_done(h) then
|
|
consume_update_check(m)
|
|
end
|
|
end
|
|
|
|
-- L.5: pump only the selected module's carousel (perf — no point
|
|
-- advancing offscreen rotation timers).
|
|
if ctx_panels.selected_entry and ctx_panels.selected_entry ~= "news" then
|
|
local s = ctx_panels.carousel_state[ctx_panels.selected_entry]
|
|
if s then ctx_panels.carousel_mod.update(s, dt) end
|
|
end
|
|
|
|
local mx, my = engine.input.get_mouse_pos()
|
|
|
|
if state == STATE_LIST then
|
|
if input.was_action_pressed("ui_back") then
|
|
state = fsm.next(state, "esc")
|
|
elseif input.was_action_pressed("ui_click") then
|
|
if not list_panel.handle_click(ctx_panels, mx, my) then
|
|
detail_panel.handle_click(ctx_panels, mx, my)
|
|
end
|
|
end
|
|
elseif state == STATE_QUIT then
|
|
if input.was_action_pressed("ui_back") then
|
|
state = fsm.next(state, "esc")
|
|
elseif input.was_action_pressed("ui_confirm") then
|
|
engine.exit(0); return
|
|
elseif input.was_action_pressed("ui_click") then
|
|
if hit(quit_buttons.yes, mx, my) then
|
|
engine.exit(0); return
|
|
elseif hit(quit_buttons.no, mx, my) then
|
|
state = fsm.next(state, "click_no")
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
function render(ctx)
|
|
local mx, my = engine.input.get_mouse_pos()
|
|
|
|
engine.render.clear_color(COLOR_BG)
|
|
|
|
list_panel.render(ctx_panels)
|
|
detail_panel.render(ctx_panels)
|
|
|
|
if state == STATE_QUIT then
|
|
engine.render.draw_rect(0, 0, screen_w, screen_h, COLOR_DIM)
|
|
local modal_w, modal_h = 360, 140
|
|
engine.render.draw_rect((screen_w - modal_w) / 2,
|
|
(screen_h - modal_h) / 2,
|
|
modal_w, modal_h, COLOR_MODAL)
|
|
local pw = engine.render.measure_text(QUIT_PROMPT, FONT_BUTTON)
|
|
engine.render.draw_text(QUIT_PROMPT, (screen_w - pw) / 2,
|
|
screen_h / 2 - 24, FONT_BUTTON, COLOR_TEXT)
|
|
|
|
for _, key in ipairs({ "yes", "no" }) do
|
|
local b = quit_buttons[key]
|
|
local color = hit(b, mx, my) and COLOR_HOVER or COLOR_BTN
|
|
engine.render.draw_rect(b.x, b.y, b.w, b.h, color)
|
|
local label = (key == "yes") and YES_LABEL or NO_LABEL
|
|
local lw = engine.render.measure_text(label, FONT_BUTTON)
|
|
engine.render.draw_text(label, b.x + (b.w - lw) / 2,
|
|
b.y + PAD_Y, FONT_BUTTON, COLOR_TEXT)
|
|
end
|
|
end
|
|
end
|
|
|
|
function cleanup(ctx)
|
|
engine.print("launcher: cleanup")
|
|
end
|