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 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 = "", 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 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 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