local input = require("lib-core.input") local git = require("lib-core.git") local depf = require("lib-management.dep-fetcher") -- fsm.lua + icons.lua live 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 icons = dofile(MODULE_DIR .. "/icons.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 ICON_GAP = 4 local ICON_STRIP_W = 140 -- reserved right-edge area inside each button 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 -- 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 ok and type(result) == "table" then m.status.dep_check_result = result else m.status.dep_check_result = { ok = false, conflicts = {}, warnings = {}, errors = {{ lib_id = "", kind = "internal", message = tostring(result) }}, closure = {}, } end end -- Build the tooltip text for whichever icon the mouse is over. -- Returns nil when no icon is hovered. local function tooltip_for(m, mx, my) local s = m.status if s.error_rect and hit(s.error_rect, mx, my) then local r = s.dep_check_result if r then if #r.conflicts > 0 then local c = r.conflicts[1] return "Pin-Konflikt: " .. c.lib_id .. " (" .. tostring(#c.pins) .. " Versionen)" elseif #r.errors > 0 then local e = r.errors[1] return "Fehler: " .. e.lib_id .. " — " .. (e.message or e.kind) end end return "Modul nicht startbereit" end if s.badge_rect and hit(s.badge_rect, mx, my) then return "Update verfuegbar: " .. (s.update_latest_tag or "?") .. " — klicken zum Aktualisieren" end if s.warn_rect and hit(s.warn_rect, mx, my) then if s.update_check_result == "fail" then local k = s.update_check_fail_kind if k == "auth-required" then return "Privat — SPOREL_GITEA_TOKEN setzen fuer Update-Check" end return "Updates konnten nicht geprueft werden (" .. (k or "fail") .. ")" end local r = s.dep_check_result if r and #r.warnings > 0 then local w = r.warnings[1] return "Dirty: " .. w.lib_id .. " (" .. (w.kind or "warn") .. ")" end return "Warnung" end if s.local_rect and hit(s.local_rect, mx, my) then return "Nur lokal vorhanden (kein Server-Repo)" end if s.spinner_rect and hit(s.spinner_rect, mx, my) then return "Pruefe auf Updates..." end return nil end -- Update flow: fetch + checkout + dep-recheck + kick fresh update-check. local function apply_update(m) local url = module_repo_url(m.id) local module_dir = engine.install_root() .. "/modules/" .. m.id if git.is_repo(module_dir) then local ok1, err1 = git.fetch(url, module_dir) if ok1 then local ok2, err2 = git.checkout(module_dir, m.status.update_latest_tag) if ok2 then refresh_module_version(m) else engine.print("launcher: checkout failed for " .. m.id .. ": " .. tostring(err2)) end else engine.print("launcher: fetch failed for " .. m.id .. ": " .. tostring(err1)) end else -- Packaged install without .git (cmake --install excludes it). -- Clone-into-temp-and-swap is slice-8 bundle work; for now -- surface a clear log message and let the dep-check error path -- show the user a red icon on next interaction. engine.print("launcher: module " .. m.id .. " has no .git — update flow requires a dev install") end run_dep_check(m) start_update_check(m) 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 } -- 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 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 local function draw_module_row(m) local mx, my = engine.input.get_mouse_pos() local color = hit(m.rect, mx, my) and COLOR_HOVER or COLOR_BTN engine.render.draw_rect(m.rect.x, m.rect.y, m.rect.w, m.rect.h, color) -- Label left-aligned with PAD_X margin (room reserved on right -- for the icon strip). engine.render.draw_text(m.name, m.rect.x + PAD_X, m.rect.y + PAD_Y, FONT_BUTTON, COLOR_TEXT) -- Icon strip: right-edge anchored, laid out right-to-left so the -- rightmost icon's x stays stable across state changes. local row_y = m.rect.y + (m.rect.h - icons.ICON_H) / 2 local right = m.rect.x + m.rect.w - PAD_X - icons.ICON_W -- Reset rects each frame so stale hit-areas don't linger when a -- status icon disappears. m.status.spinner_rect = nil m.status.warn_rect = nil m.status.badge_rect = nil m.status.error_rect = nil m.status.local_rect = nil local dep = m.status.dep_check_result local uchk = m.status.update_check_result -- 1. Error icon (rightmost) — conflict or error from dep-check. if dep and (not dep.ok) then m.status.error_rect = icons.draw_error(right, row_y) right = right - icons.ICON_W - ICON_GAP end -- 2. Update badge — variable width; subtract its returned width. if uchk == "available" and m.status.update_latest_tag then local r = icons.draw_update_badge( right - 40, row_y, m.status.update_latest_tag) m.status.badge_rect = r right = r.x - ICON_GAP - icons.ICON_W end -- 3. Warn icon — update-check failed (auth/network) or dirty deps. -- Local-only is NOT a warn — it's an informational state below. local warn_needed = (uchk == "fail") or (dep and dep.ok and dep.warnings and #dep.warnings > 0) if warn_needed then m.status.warn_rect = icons.draw_warn(right, row_y) right = right - icons.ICON_W - ICON_GAP end -- 3b. Local-only marker — repo not on server (404 even with creds). if uchk == "local-only" then m.status.local_rect = icons.draw_local(right, row_y) right = right - icons.ICON_W - ICON_GAP end -- 4. Spinner — leftmost in the strip, still-pending update-check. if uchk == "pending" then m.status.spinner_rect = icons.draw_spinner(right, row_y, frames) 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