diff --git a/.gitignore b/.gitignore index 9b264c3..11757f4 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,4 @@ ARBOR_ARCHITECTURE_ROADMAP.md ONLINE_AUTH_HARDENING_PLAN.md PHASE1_PROGRESS.md FEATURE_PROPOSALS.md +MIGRATE_RESPONSIVE.md diff --git a/README.md b/README.md index 8e9df84..8c2227b 100644 --- a/README.md +++ b/README.md @@ -441,6 +441,7 @@ Overlay add is disabled by default. To enable it, set `ARBOR_ENABLE_OVERLAY_ADD= - **Portage News** — reads GLEP 42 news items from the local tree, tracks read state per-item, shows an unread count badge on the dashboard - **GLSA advisories** — lists security advisories that affect the installed system via `glsa-check`, with severity badges, affected packages, and a quick-fix button - **Config snapshot** — export `/etc/portage/` (including the `make.profile` symlink) and world files as a zip; import with an automatic timestamped backup of the current config before applying +- **Kernel management** *(beta)* — full lifecycle wizard for source kernels: install or update via Portage with pretend preview and autounmask support; download vanilla kernels directly from kernel.org; browse and clean up `/usr/src/linux-*` source directories; switch the active symlink; 7-step manual build wizard (kernel config with optional `.config` copy from another source, `make modules_install`, `make install`, dracut initramfs generation, `@module-rebuild`, Limine bootloader auto-update); manage bootloaders including a Limine config editor; clean up old `/boot` files and orphaned `/lib/modules` directories; reboot with double confirmation (type `REBOOT` + approval); wizard state persisted in the browser across page reloads and reconnects to in-progress build jobs after refresh. Functional but not yet fully stabilized — always keep a working fallback kernel in your bootloader. - **Jobs** — view active jobs, reopen live output, browse persisted history with log viewing, delete, and purge actions (stored in SQLite at `/var/lib/arbor/history.db`), and surface recovered orphaned/unknown jobs after daemon restart ## Screenshots diff --git a/backend/arbor/action_security.py b/backend/arbor/action_security.py index 6604bbe..67819ff 100644 --- a/backend/arbor/action_security.py +++ b/backend/arbor/action_security.py @@ -39,12 +39,21 @@ "glsa_list", "eclean_pretend", "snapshot_export", + "disk_usage", + "kernel_status", + "kernel_available", + "limine_config_read", } _PRETEND_COMMANDS = { "emerge_pretend", "emerge_uninstall_pretend", "emerge_depclean_pretend", + "revdep_rebuild_pretend", + "kernel_install_pretend", + "kernel_oldconfig", + "kernel_switch_src", + "kernel_copy_config", } _APPROVAL_REQUIRED_COMMANDS = { @@ -60,6 +69,22 @@ "history_purge", "eclean_run", "snapshot_import", + "revdep_rebuild", + "kernel_install", + "kernel_bootloader_update", + "kernel_boot_clean", + "kernel_modules_clean", + "kernel_src_clean", + "kernel_olddefconfig", + "kernel_build", + "kernel_modules_install", + "kernel_make_install", + "kernel_initramfs", + "kernel_module_rebuild", + "kernel_download_tarball", + "kernel_reboot", + "limine_config_write", + "limine_config_auto_update", } _TARGET_KEYS = ("atom", "name", "cfg_file", "job_id") diff --git a/backend/arbor/authorization.py b/backend/arbor/authorization.py index 47b3f77..108c9f2 100644 --- a/backend/arbor/authorization.py +++ b/backend/arbor/authorization.py @@ -72,6 +72,31 @@ class StepUpRequiredError(PermissionError): "totp_enroll_begin", "totp_enroll_confirm", "totp_disable", + "revdep_rebuild_pretend", + "revdep_rebuild", + "disk_usage", + "kernel_status", + "kernel_available", + "kernel_install_pretend", + "kernel_install", + "kernel_bootloader_update", + "kernel_boot_clean", + "kernel_modules_clean", + "kernel_src_clean", + "kernel_oldconfig", + "kernel_olddefconfig", + "kernel_build", + "kernel_initramfs", + "kernel_module_rebuild", + "kernel_download_tarball", + "kernel_reboot", + "kernel_switch_src", + "kernel_copy_config", + "kernel_modules_install", + "kernel_make_install", + "limine_config_read", + "limine_config_write", + "limine_config_auto_update", } _ROLE_ALLOWED_CLASSES = { diff --git a/backend/arbor/main.py b/backend/arbor/main.py index f579de4..e06fc73 100644 --- a/backend/arbor/main.py +++ b/backend/arbor/main.py @@ -10,7 +10,7 @@ from pathlib import Path from typing import Annotated -from fastapi import Depends, FastAPI, File, Request, UploadFile, WebSocket, WebSocketDisconnect, Query +from fastapi import Depends, FastAPI, File, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles @@ -805,6 +805,27 @@ async def ws_emerge_preserved_rebuild( ) +@app.websocket("/ws/emerge/revdep-pretend") +async def ws_revdep_pretend(websocket: WebSocket): + await _ws_job_cmd(websocket, "revdep_rebuild_pretend", {}) + + +@app.websocket("/ws/emerge/revdep-rebuild") +async def ws_revdep_rebuild( + websocket: WebSocket, + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_job_cmd( + websocket, + "revdep_rebuild", + { + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + @app.websocket("/ws/emerge/world-pretend") async def ws_emerge_world_pretend(websocket: WebSocket): await _ws_job_cmd(websocket, "world_updates", {}) @@ -996,6 +1017,11 @@ async def pkg_stats(auth: Auth): return data +@app.get("/api/storage-stats") +async def storage_stats(auth: Auth): + return await query_one("disk_usage") + + @app.get("/api/analytics/compile-time-by-category") async def analytics_compile_time(auth: Auth): """ @@ -1276,6 +1302,372 @@ async def snapshot_import(auth: Auth, request: Request, file: UploadFile = File( pass +# --------------------------------------------------------------------------- +# Kernel status / available / kernel.org releases +# --------------------------------------------------------------------------- + +_kernel_org_cache: dict = {"data": None, "ts": 0.0} +_KERNEL_ORG_CACHE_TTL = 3600.0 +_KERNEL_ORG_URL = "https://www.kernel.org/releases.json" + + +@app.get("/api/kernel/status") +async def kernel_status(auth: Auth): + return await query_one("kernel_status") + + +@app.get("/api/kernel/available") +async def kernel_available(auth: Auth): + results = await query_all("kernel_available") + return [r for r in results if "cpv" in r] + + +@app.get("/api/kernel/org-releases") +async def kernel_org_releases(auth: Auth): + import time as _time + import httpx + global _kernel_org_cache + now = _time.time() + if _kernel_org_cache["data"] is not None and now - _kernel_org_cache["ts"] < _KERNEL_ORG_CACHE_TTL: + return _kernel_org_cache["data"] + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.get(_KERNEL_ORG_URL) + resp.raise_for_status() + data = resp.json() + _kernel_org_cache = {"data": data, "ts": now} + return data + except Exception as exc: + log.warning("kernel.org fetch failed: %s", exc) + if _kernel_org_cache["data"] is not None: + return _kernel_org_cache["data"] + return JSONResponse(status_code=502, content={"error": f"Failed to fetch kernel.org releases: {exc}"}) + + +async def _ws_kernel_mutating(websocket: WebSocket, daemon_cmd: str, args: dict): + """Stream a mutating kernel daemon command directly (no background job).""" + if not await _ws_require_auth(websocket): + return + if not await _ws_step_up_if_mutating(websocket, daemon_cmd, args): + return + try: + daemon_args = {**args, "request_principal": _websocket_principal_binding(websocket)} + async for chunk in query(daemon_cmd, daemon_args): + await websocket.send_text(json.dumps(chunk)) + if chunk.get("done") or chunk.get("error"): + break + except WebSocketDisconnect: + pass + except Exception as e: + try: + await websocket.send_text(json.dumps({"error": str(e), "done": True})) + except Exception: + pass + finally: + try: + await websocket.close() + except Exception: + pass + + +@app.websocket("/ws/kernel/install-pretend") +async def ws_kernel_install_pretend(websocket: WebSocket, atom: str = Query(default="")): + await _ws_emerge(websocket, "kernel_install_pretend", atom) + + +@app.websocket("/ws/kernel/install") +async def ws_kernel_install( + websocket: WebSocket, + atom: str = Query(default=""), + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_job_cmd( + websocket, + "kernel_install", + { + "atom": atom, + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/bootloader-update") +async def ws_kernel_bootloader_update( + websocket: WebSocket, + bootloader_name: str = Query(default=""), + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_kernel_mutating( + websocket, + "kernel_bootloader_update", + { + "bootloader_name": bootloader_name, + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/boot-clean") +async def ws_kernel_boot_clean( + websocket: WebSocket, + versions: str = Query(default=""), + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + ver_list = [v.strip() for v in versions.split(",") if v.strip()] + await _ws_kernel_mutating( + websocket, + "kernel_boot_clean", + { + "versions": ver_list, + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/src-clean") +async def ws_kernel_src_clean( + websocket: WebSocket, + names: str = Query(default=""), + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + name_list = [n.strip() for n in names.split(",") if n.strip()] + await _ws_kernel_mutating( + websocket, + "kernel_src_clean", + { + "names": name_list, + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/modules-clean") +async def ws_kernel_modules_clean( + websocket: WebSocket, + versions: str = Query(default=""), + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + ver_list = [v.strip() for v in versions.split(",") if v.strip()] + await _ws_kernel_mutating( + websocket, + "kernel_modules_clean", + { + "versions": ver_list, + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/oldconfig") +async def ws_kernel_oldconfig(websocket: WebSocket): + await _ws_kernel_mutating(websocket, "kernel_oldconfig", {}) + + +@app.websocket("/ws/kernel/olddefconfig") +async def ws_kernel_olddefconfig( + websocket: WebSocket, + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_kernel_mutating( + websocket, + "kernel_olddefconfig", + {"approval_request_id": approval_request_id, "approval_token": approval_token}, + ) + + +@app.websocket("/ws/kernel/build") +async def ws_kernel_build( + websocket: WebSocket, + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_job_cmd( + websocket, + "kernel_build", + { + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/initramfs") +async def ws_kernel_initramfs( + websocket: WebSocket, + kver: str = Query(default=""), + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_kernel_mutating( + websocket, + "kernel_initramfs", + { + "kver": kver, + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/module-rebuild") +async def ws_kernel_module_rebuild( + websocket: WebSocket, + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_job_cmd( + websocket, + "kernel_module_rebuild", + { + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/download-tarball") +async def ws_kernel_download_tarball( + websocket: WebSocket, + url: str = Query(default=""), + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_kernel_mutating( + websocket, + "kernel_download_tarball", + { + "url": url, + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/modules-install") +async def ws_kernel_modules_install( + websocket: WebSocket, + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_job_cmd( + websocket, + "kernel_modules_install", + { + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/make-install") +async def ws_kernel_make_install( + websocket: WebSocket, + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_job_cmd( + websocket, + "kernel_make_install", + { + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +@app.websocket("/ws/kernel/switch-src") +async def ws_kernel_switch_src(websocket: WebSocket, dirname: str = Query(default="")): + await _ws_kernel_mutating(websocket, "kernel_switch_src", {"dirname": dirname}) + + +@app.websocket("/ws/kernel/copy-config") +async def ws_kernel_copy_config(websocket: WebSocket, src_dirname: str = Query(default="")): + await _ws_kernel_mutating(websocket, "kernel_copy_config", {"src_dirname": src_dirname}) + + +@app.websocket("/ws/kernel/reboot") +async def ws_kernel_reboot( + websocket: WebSocket, + approval_request_id: str = Query(default=""), + approval_token: str = Query(default=""), +): + await _ws_kernel_mutating( + websocket, + "kernel_reboot", + { + "approval_request_id": approval_request_id, + "approval_token": approval_token, + }, + ) + + +# --------------------------------------------------------------------------- +# Limine config +# --------------------------------------------------------------------------- + +@app.get("/api/limine/config") +async def api_limine_config_get(auth: Auth): + result = await query_one("limine_config_read", {}) + if result.get("error"): + raise HTTPException(status_code=500, detail=result["error"]) + return result + + +@app.post("/api/limine/config") +async def api_limine_config_post(auth: Auth, request: Request): + require_min_role("owner") + require_recent_step_up_unless_cli_mode() + body = await _json_object_body(request) + if isinstance(body, JSONResponse): + return body + config_json = body.get("config_json", "") + approval_request_id = str(body.get("approval_request_id", "")).strip() + approval_token = str(body.get("approval_token", "")).strip() + result = await query_one( + "limine_config_write", + { + "config_json": config_json, + "approval_request_id": approval_request_id, + "approval_token": approval_token, + "request_principal": _request_principal_binding(request), + }, + ) + return result + + +@app.post("/api/limine/auto-update") +async def api_limine_auto_update(auth: Auth, request: Request): + require_min_role("owner") + require_recent_step_up_unless_cli_mode() + body = await _json_object_body(request) + if isinstance(body, JSONResponse): + return body + kver = str(body.get("kver", "")).strip() + approval_request_id = str(body.get("approval_request_id", "")).strip() + approval_token = str(body.get("approval_token", "")).strip() + result = await query_one( + "limine_config_auto_update", + { + "kver": kver, + "approval_request_id": approval_request_id, + "approval_token": approval_token, + "request_principal": _request_principal_binding(request), + }, + ) + return result + + # --------------------------------------------------------------------------- # Serve frontend static files # --------------------------------------------------------------------------- diff --git a/backend/daemon/main.py b/backend/daemon/main.py index d9f9ddc..c6e77e2 100644 --- a/backend/daemon/main.py +++ b/backend/daemon/main.py @@ -111,6 +111,31 @@ "eclean_run", "snapshot_export", "snapshot_import", + "revdep_rebuild_pretend", + "revdep_rebuild", + "disk_usage", + "kernel_status", + "kernel_available", + "kernel_install_pretend", + "kernel_install", + "kernel_bootloader_update", + "kernel_boot_clean", + "kernel_modules_clean", + "kernel_src_clean", + "kernel_oldconfig", + "kernel_olddefconfig", + "kernel_build", + "kernel_initramfs", + "kernel_module_rebuild", + "kernel_download_tarball", + "kernel_reboot", + "kernel_switch_src", + "kernel_copy_config", + "kernel_modules_install", + "kernel_make_install", + "limine_config_read", + "limine_config_write", + "limine_config_auto_update", } # --------------------------------------------------------------------------- @@ -2158,7 +2183,7 @@ def _canonical_approval_args(action_cmd: str, args: dict | None) -> dict: if action_cmd == "emerge_world_update": opts = ",".join(_parse_opts(str(data.get("opts", "")), _UPDATE_OPTS)) return {"opts": opts} if opts else {} - if action_cmd in {"emerge_depclean", "emerge_preserved_rebuild", "emerge_sync"}: + if action_cmd in {"emerge_depclean", "emerge_preserved_rebuild", "emerge_sync", "revdep_rebuild"}: return {} if action_cmd == "overlay_sync": return {"name": str(data.get("name", "")).strip()} @@ -2614,6 +2639,7 @@ async def _start_background_job( action_cmd: str = "", action_args: dict | None = None, stderr=asyncio.subprocess.STDOUT, + cwd: str | None = None, ): meta = action_metadata(action_cmd, action_args or {}) if action_cmd else {} async with _get_jobs_lock(): @@ -2627,6 +2653,7 @@ async def _start_background_job( stdout=asyncio.subprocess.PIPE, stderr=stderr, env=_EMERGE_ENV, + **({"cwd": cwd} if cwd is not None else {}), ) job_id = str(uuid.uuid4()) _jobs[job_id] = _Job( @@ -2755,6 +2782,1312 @@ async def cmd_emerge_preserved_rebuild(_args): yield item +async def cmd_revdep_rebuild_pretend(_args): + async for item in _start_background_job( + "@revdep-pretend", + ["revdep-rebuild", "--pretend"], + kind="revdep-pretend", + action_cmd="revdep_rebuild_pretend", + action_args={}, + ): + yield item + + +async def cmd_revdep_rebuild(_args): + approval_error = await in_thread( + _require_approval, + "revdep_rebuild", + _approval_payload("revdep_rebuild", _args), + ) + if approval_error: + yield approval_error + return + async for item in _start_background_job( + "@revdep-rebuild", + ["revdep-rebuild"], + kind="revdep-rebuild", + action_cmd="revdep_rebuild", + action_args={}, + ): + yield item + + +def _disk_usage() -> dict: + import subprocess + paths = { + "distfiles": "/var/cache/distfiles", + "binpkgs": "/var/cache/binpkgs", + "tmp_portage": "/var/tmp/portage", + } + result = {} + for key, path in paths.items(): + try: + out = subprocess.check_output( + ["du", "-sb", path], stderr=subprocess.DEVNULL, timeout=10 + ).decode() + result[key] = int(out.split()[0]) + except Exception: + result[key] = 0 + return result + + +async def cmd_disk_usage(_args): + yield await in_thread(_disk_usage) + + +# --------------------------------------------------------------------------- +# Kernel status / information +# --------------------------------------------------------------------------- + +def _kernel_status() -> dict: + import re, subprocess, os + from pathlib import Path + _maybe_reload_portage() + import portage + + try: + running = subprocess.check_output(["uname", "-r"], text=True, timeout=5).strip() + except Exception: + running = "unknown" + + src_link = Path("/usr/src/linux") + src_target = "" + try: + if src_link.is_symlink(): + src_target = os.readlink(str(src_link)) + except OSError: + pass + + vdb = portage.db[portage.root]["vartree"].dbapi + kernel_pkgs = [] + for cpv in sorted(vdb.cpv_all()): + if not cpv.startswith("sys-kernel/"): + continue + _cat, pf = cpv.split("/", 1) + slot, build_time = vdb.aux_get(cpv, ["SLOT", "BUILD_TIME"]) + kernel_pkgs.append({"cpv": cpv, "pf": pf, "slot": slot, "build_time": build_time}) + + installkernel = bool(vdb.match("sys-kernel/installkernel")) + + boot_dir = Path("/boot") + boot_size = 0 + kernels = [] + if boot_dir.exists(): + try: + out = subprocess.check_output( + ["du", "-sb", "/boot"], stderr=subprocess.DEVNULL, timeout=10, text=True + ) + boot_size = int(out.split()[0]) + except Exception: + pass + + vmlinuz_re = re.compile(r"^vmlinuz-(.+?)(?:\.old)?$") + config_re = re.compile(r"^config-(.+?)(?:\.old)?$") + initrd_re = re.compile(r"^initramfs-(.+?)\.img(?:\.old)?$") + sysmap_re = re.compile(r"^System\.map-(.+?)(?:\.old)?$") + versions: dict = {} + all_files = sorted(boot_dir.iterdir()) + + # Pass 1: build versions dict from vmlinuz files only. + # Must be separate because config/initramfs sort before vmlinuz (c,i < v) + # so a single pass would miss associated files for not-yet-seen versions. + for f in all_files: + m = vmlinuz_re.match(f.name) + if not m: + continue + ver = m.group(1) + is_old = f.name.endswith(".old") + try: + sz = f.stat().st_size + except OSError: + sz = 0 + if ver not in versions: + versions[ver] = { + "version": ver, "running": ver == running, + "has_vmlinuz": False, "has_config": False, + "has_initramfs": False, "has_system_map": False, + "old_count": 0, "size": 0, + } + versions[ver]["size"] += sz + if is_old: + versions[ver]["old_count"] += 1 + else: + versions[ver]["has_vmlinuz"] = True + + # Pass 2: associate config, initramfs, System.map with known versions. + for f in all_files: + name = f.name + try: + sz = f.stat().st_size + except OSError: + sz = 0 + for pattern, field in [ + (config_re, "has_config"), + (initrd_re, "has_initramfs"), + (sysmap_re, "has_system_map"), + ]: + m = pattern.match(name) + if m: + ver = m.group(1) + if ver in versions: + versions[ver]["size"] += sz + if name.endswith(".old"): + versions[ver]["old_count"] += 1 + else: + versions[ver][field] = True + break + + kernels = sorted(versions.values(), key=lambda x: x["version"]) + + bootloaders = [] + for cfg_path, name, label in [ + ("/boot/grub/grub.cfg", "grub2", "GRUB2"), + ("/boot/grub2/grub.cfg", "grub2", "GRUB2"), + ("/boot/limine.conf", "limine", "Limine"), + ("/boot/loader/loader.conf", "systemd-boot", "systemd-boot"), + ("/efi/loader/loader.conf", "systemd-boot", "systemd-boot"), + ("/boot/syslinux/syslinux.cfg", "syslinux", "Syslinux"), + ("/boot/extlinux/extlinux.conf", "syslinux", "Extlinux"), + ]: + if Path(cfg_path).exists(): + if not any(b["name"] == name for b in bootloaders): + bootloaders.append({"name": name, "label": label, "cfg": cfg_path}) + + limine_disk = None + try: + with open("/proc/mounts") as f: + for line in f: + parts = line.split() + if len(parts) >= 2 and parts[1] == "/boot": + dev = parts[0] + limine_disk = re.sub(r"p?\d+$", "", dev) + break + except OSError: + pass + + # Scan /usr/src for linux-* source directories + src_dirs = [] + try: + src_base = Path("/usr/src") + installed_pfs = {pkg["pf"] for pkg in kernel_pkgs} + for entry in sorted(src_base.iterdir()): + if not (entry.is_dir() and entry.name.startswith("linux-")): + continue + name = entry.name + kver = name[6:] # strip "linux-" + # Heuristic: portage kernels have suffix like -gentoo-rN + from_portage = name in installed_pfs or any( + p["pf"].endswith(kver) or kver in p["pf"] for p in kernel_pkgs + ) + # Derive real kver from source Makefile. + # The dir name may omit SUBLEVEL (e.g. linux-7.1-rc4 → 7.1.0-rc4). + real_kver = kver + try: + mk = entry / "Makefile" + if mk.is_file(): + _vp: dict = {} + with mk.open() as _mf: + for _i, _ml in enumerate(_mf): + if _i > 30: + break + _ml = _ml.strip() + if not _ml or _ml.startswith('#'): + continue + if '=' in _ml: + _lhs, _, _rhs = _ml.partition('=') + _lhs = _lhs.strip() + if _lhs in ("VERSION", "PATCHLEVEL", "SUBLEVEL", "EXTRAVERSION"): + _vp[_lhs] = _rhs.strip() + if "VERSION" in _vp and "PATCHLEVEL" in _vp: + _sl = _vp.get("SUBLEVEL", "0") or "0" + _ev = _vp.get("EXTRAVERSION", "") + real_kver = f"{_vp['VERSION']}.{_vp['PATCHLEVEL']}.{_sl}{_ev}" + except OSError: + pass + # For module/boot checks try both dirname-kver and Makefile-kver + _check_kvers = list(dict.fromkeys([real_kver, kver])) # dedup, real_kver first + # Check build artifacts + built = False + try: + arch_dir = entry / "arch" + if arch_dir.is_dir(): + for arch_sub in arch_dir.iterdir(): + boot_d = arch_sub / "boot" + if boot_d.is_dir(): + for img in ("bzImage", "zImage", "Image", "Image.gz"): + if (boot_d / img).is_file(): + built = True + break + if built: + break + if not built: + built = (entry / "vmlinux").is_file() + except OSError: + pass + modules_installed = any( + Path(f"/lib/modules/{k}/modules.dep").is_file() for k in _check_kvers + ) + kernel_in_boot = any( + Path(f"/boot/vmlinuz-{k}").is_file() for k in _check_kvers + ) + initramfs_in_boot = any( + Path(f"/boot/initramfs-{k}.img").is_file() for k in _check_kvers + ) + src_size = 0 + try: + out = subprocess.check_output( + ["du", "-sb", str(entry)], stderr=subprocess.DEVNULL, timeout=15, text=True + ) + src_size = int(out.split()[0]) + except Exception: + pass + src_dirs.append({ + "name": name, + "kver": kver, + "real_kver": real_kver, + "active": name == src_target or ("/" + name) == src_target, + "portage": from_portage, + "has_config": (entry / ".config").is_file(), + "built": built, + "modules_installed": modules_installed, + "kernel_in_boot": kernel_in_boot, + "initramfs_in_boot": initramfs_in_boot, + "size": src_size, + }) + except OSError: + pass + + # Scan /lib/modules for installed module directories + modules = [] + try: + mod_base = Path("/lib/modules") + boot_versions = {k["version"] for k in kernels} + for entry in sorted(mod_base.iterdir()): + if not entry.is_dir(): + continue + ver = entry.name + mod_size = 0 + try: + out = subprocess.check_output( + ["du", "-sb", str(entry)], stderr=subprocess.DEVNULL, timeout=10, text=True + ) + mod_size = int(out.split()[0]) + except Exception: + pass + modules.append({ + "version": ver, + "running": ver == running, + "in_boot": ver in boot_versions, + "size": mod_size, + }) + except OSError: + pass + + return { + "running": running, + "src_link": str(src_link), + "src_target": src_target, + "installkernel": installkernel, + "installed": kernel_pkgs, + "boot_size": boot_size, + "kernels": kernels, + "bootloaders": bootloaders, + "limine_disk": limine_disk, + "src_dirs": src_dirs, + "modules": modules, + } + + +def _kernel_available() -> list: + _maybe_reload_portage() + import portage + porttree = portage.db[portage.root]["porttree"].dbapi + vdb = portage.db[portage.root]["vartree"].dbapi + + UPGRADEABLE_CPS = [ + "sys-kernel/gentoo-sources", + "sys-kernel/gentoo-kernel", + "sys-kernel/gentoo-kernel-bin", + "sys-kernel/vanilla-sources", + "sys-kernel/hardened-sources", + ] + results = [] + for cp in UPGRADEABLE_CPS: + try: + versions = porttree.cp_list(cp) + except Exception: + continue + if not versions: + continue + installed_set = set(vdb.match(cp)) + for cpv in versions[-5:]: + _cat, pf = cpv.split("/", 1) + desc = "" + try: + desc = porttree.aux_get(cpv, ["DESCRIPTION"])[0] + except Exception: + pass + results.append({ + "cpv": cpv, "cp": cp, "pf": pf, + "installed": cpv in installed_set, + "description": desc, + }) + + # Sort newest-first across all package types using portage's version comparator. + try: + import functools + from portage.versions import vercmp, cpv_getkey as _cpv_getkey + + def _ver_of(cpv): + cp = _cpv_getkey(cpv) + return cpv[len(cp) + 1:] if cp else cpv + + results.sort( + key=lambda x: functools.cmp_to_key(vercmp)(_ver_of(x["cpv"])), + reverse=True, + ) + except Exception: + pass + + return results + + +async def cmd_kernel_status(_args): + yield await in_thread(_kernel_status) + + +async def cmd_kernel_available(_args): + for item in await in_thread(_kernel_available): + yield item + yield {"done": True} + + +async def cmd_kernel_install_pretend(args): + atom_raw = args.get("atom", "") + atom = _checked_atom(atom_raw) + if not atom or "sys-kernel/" not in atom: + yield {"error": "invalid kernel atom"} + return + proc = None + try: + proc = await asyncio.create_subprocess_exec( + "emerge", "--pretend", "--verbose", "--color=n", atom, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + env=_EMERGE_ENV, + ) + async for raw in proc.stdout: + yield {"line": _ANSI.sub("", raw.decode(errors="replace").rstrip())} + await proc.wait() + yield {"done": True, "returncode": proc.returncode} + finally: + await _terminate_subprocess(proc) + + +async def cmd_kernel_install(args): + atom_raw = args.get("atom", "") + atom = _checked_atom(atom_raw) + if not atom or "sys-kernel/" not in atom: + yield {"error": "invalid kernel atom"} + return + approval_error = await in_thread( + _require_approval, + "kernel_install", + _approval_payload("kernel_install", args, {"atom": atom}), + ) + if approval_error: + yield approval_error + return + async for item in _start_background_job( + atom, + ["emerge", "--verbose", "--color=n", atom], + kind="install", + action_cmd="kernel_install", + action_args={"atom": atom}, + ): + yield item + + +async def cmd_kernel_bootloader_update(args): + bootloader_name = str(args.get("bootloader_name", "")).strip() + if bootloader_name not in {"grub2", "systemd-boot"}: + yield {"error": "invalid bootloader_name; must be 'grub2' or 'systemd-boot'"} + return + approval_error = await in_thread( + _require_approval, + "kernel_bootloader_update", + _approval_payload("kernel_bootloader_update", args, {"bootloader_name": bootloader_name}), + ) + if approval_error: + yield approval_error + return + if bootloader_name == "grub2": + from pathlib import Path as _Path + if _Path("/boot/grub2/grub.cfg").exists(): + grub_cfg = "/boot/grub2/grub.cfg" + else: + grub_cfg = "/boot/grub/grub.cfg" + cmd = ["grub-mkconfig", "-o", grub_cfg] + else: + cmd = ["bootctl", "update"] + proc = None + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + env=_EMERGE_ENV, + ) + async for raw in proc.stdout: + yield {"line": _ANSI.sub("", raw.decode(errors="replace").rstrip())} + await proc.wait() + yield {"done": True, "returncode": proc.returncode} + finally: + await _terminate_subprocess(proc) + + +async def cmd_kernel_boot_clean(args): + import re as _re + versions = args.get("versions", []) + if not isinstance(versions, list) or not versions: + yield {"error": "versions must be a non-empty list of strings"} + return + _ver_re = _re.compile(r'^[\w.+-]+$') + for v in versions: + if not isinstance(v, str) or not _ver_re.match(v): + yield {"error": f"invalid version string: {v!r}"} + return + approval_error = await in_thread( + _require_approval, + "kernel_boot_clean", + _approval_payload("kernel_boot_clean", args, {"versions": versions}), + ) + if approval_error: + yield approval_error + return + + def _do_clean(): + from pathlib import Path as _Path + boot = _Path("/boot") + lines = [] + for ver in versions: + candidates = [ + f"vmlinuz-{ver}", f"vmlinuz-{ver}.old", + f"initramfs-{ver}.img", f"initramfs-{ver}.img.old", + f"initrd-{ver}.img", f"initrd-{ver}.img.old", + f"config-{ver}", f"config-{ver}.old", + f"System.map-{ver}", f"System.map-{ver}.old", + ] + for name in candidates: + path = boot / name + if path.exists(): + path.unlink(missing_ok=True) + lines.append(f"Removed: {path}") + else: + lines.append(f"Skipped (not found): {path}") + return lines + + result_lines = await in_thread(_do_clean) + for line in result_lines: + yield {"line": line} + yield {"done": True, "returncode": 0} + + +async def cmd_kernel_modules_clean(args): + import re as _re + versions = args.get("versions", []) + if not isinstance(versions, list) or not versions: + yield {"error": "versions must be a non-empty list of strings"} + return + _ver_re = _re.compile(r'^[\w.+-]+$') + for v in versions: + if not isinstance(v, str) or not _ver_re.match(v): + yield {"error": f"invalid version string: {v!r}"} + return + approval_error = await in_thread( + _require_approval, + "kernel_modules_clean", + _approval_payload("kernel_modules_clean", args, {"versions": versions}), + ) + if approval_error: + yield approval_error + return + + def _do_clean(): + import shutil as _shutil + from pathlib import Path as _Path + mod_base = _Path("/lib/modules") + running = "" + try: + import subprocess as _sp + running = _sp.check_output(["uname", "-r"], text=True, timeout=5).strip() + except Exception: + pass + lines = [] + for ver in versions: + if ver == running: + lines.append(f"Skipped (running): /lib/modules/{ver}") + continue + path = mod_base / ver + if path.exists() and path.is_dir(): + try: + _shutil.rmtree(str(path)) + lines.append(f"Removed: /lib/modules/{ver}") + except Exception as e: + lines.append(f"Error removing /lib/modules/{ver}: {e}") + else: + lines.append(f"Skipped (not found): /lib/modules/{ver}") + return lines + + result_lines = await in_thread(_do_clean) + for line in result_lines: + yield {"line": line} + yield {"done": True, "returncode": 0} + + +async def cmd_kernel_src_clean(args): + import re as _re + names = args.get("names", []) + if not isinstance(names, list) or not names: + yield {"error": "names must be a non-empty list of strings"} + return + _name_re = _re.compile(r'^linux-[\w.+-]+$') + for n in names: + if not isinstance(n, str) or not _name_re.match(n): + yield {"error": f"invalid source name: {n!r}"} + return + approval_error = await in_thread( + _require_approval, + "kernel_src_clean", + _approval_payload("kernel_src_clean", args, {"names": names}), + ) + if approval_error: + yield approval_error + return + + def _do_clean(): + import shutil as _shutil, os as _os + from pathlib import Path as _Path + src_base = _Path("/usr/src") + # Resolve active symlink + active = "" + try: + lnk = src_base / "linux" + if lnk.is_symlink(): + target = _os.readlink(str(lnk)) + active = target.lstrip("/").split("/")[-1] if "/" in target else target + except OSError: + pass + lines = [] + for name in names: + if name == active: + lines.append(f"Skipped (active symlink): /usr/src/{name}") + continue + path = src_base / name + if path.exists() and path.is_dir(): + try: + _shutil.rmtree(str(path)) + lines.append(f"Removed: /usr/src/{name}") + except Exception as e: + lines.append(f"Error removing /usr/src/{name}: {e}") + else: + lines.append(f"Skipped (not found): /usr/src/{name}") + return lines + + result_lines = await in_thread(_do_clean) + for line in result_lines: + yield {"line": line} + yield {"done": True, "returncode": 0} + + +async def cmd_kernel_oldconfig(_args): + from pathlib import Path as _Path + src = _Path("/usr/src/linux").resolve() + if not src.exists() or not src.is_dir(): + yield {"error": f"/usr/src/linux does not resolve to a directory (got {src})"} + return + src_dir = str(src) + proc = None + try: + proc = await asyncio.create_subprocess_exec( + "make", "listnewconfig", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + cwd=src_dir, + ) + async for raw in proc.stdout: + yield {"line": _ANSI.sub("", raw.decode(errors="replace").rstrip())} + await proc.wait() + yield {"done": True, "returncode": proc.returncode} + finally: + await _terminate_subprocess(proc) + + +async def cmd_kernel_olddefconfig(args): + from pathlib import Path as _Path + src = _Path("/usr/src/linux").resolve() + if not src.exists() or not src.is_dir(): + yield {"error": f"/usr/src/linux does not resolve to a directory (got {src})"} + return + src_dir = str(src) + approval_error = await in_thread( + _require_approval, + "kernel_olddefconfig", + _approval_payload("kernel_olddefconfig", args, {}), + ) + if approval_error: + yield approval_error + return + proc = None + try: + proc = await asyncio.create_subprocess_exec( + "make", "olddefconfig", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + cwd=src_dir, + ) + async for raw in proc.stdout: + yield {"line": _ANSI.sub("", raw.decode(errors="replace").rstrip())} + await proc.wait() + yield {"done": True, "returncode": proc.returncode} + finally: + await _terminate_subprocess(proc) + + +async def cmd_kernel_build(args): + import subprocess as _subprocess + from pathlib import Path as _Path + src = _Path("/usr/src/linux").resolve() + if not src.exists() or not src.is_dir(): + yield {"error": f"/usr/src/linux does not resolve to a directory (got {src})"} + return + src_dir = str(src) + nproc = _subprocess.check_output(["nproc"], text=True, timeout=5).strip() + approval_error = await in_thread( + _require_approval, + "kernel_build", + _approval_payload("kernel_build", args, {}), + ) + if approval_error: + yield approval_error + return + async for item in _start_background_job( + "kernel-build", + ["make", f"-j{nproc}"], + kind="kernel-build", + action_cmd="kernel_build", + action_args={}, + cwd=src_dir, + ): + yield item + + +async def cmd_kernel_modules_install(args): + from pathlib import Path as _Path + src = _Path("/usr/src/linux").resolve() + if not src.exists() or not src.is_dir(): + yield {"error": f"/usr/src/linux does not resolve to a directory (got {src})"} + return + approval_error = await in_thread( + _require_approval, + "kernel_modules_install", + _approval_payload("kernel_modules_install", args, {}), + ) + if approval_error: + yield approval_error + return + async for item in _start_background_job( + "kernel-modules-install", + ["make", "modules_install"], + kind="kernel-modules-install", + action_cmd="kernel_modules_install", + action_args={}, + cwd=str(src), + ): + yield item + + +async def cmd_kernel_make_install(args): + from pathlib import Path as _Path + src = _Path("/usr/src/linux").resolve() + if not src.exists() or not src.is_dir(): + yield {"error": f"/usr/src/linux does not resolve to a directory (got {src})"} + return + approval_error = await in_thread( + _require_approval, + "kernel_make_install", + _approval_payload("kernel_make_install", args, {}), + ) + if approval_error: + yield approval_error + return + async for item in _start_background_job( + "kernel-make-install", + ["make", "install"], + kind="kernel-make-install", + action_cmd="kernel_make_install", + action_args={}, + cwd=str(src), + ): + yield item + + +async def cmd_kernel_initramfs(args): + import re as _re + from pathlib import Path as _Path + kver = args.get("kver", "") + if not kver or not isinstance(kver, str): + yield {"error": "kver is required"} + return + if len(kver) > 64 or not _re.match(r'^[\w.+-]+$', kver): + yield {"error": f"invalid kver: {kver!r}"} + return + approval_error = await in_thread( + _require_approval, + "kernel_initramfs", + _approval_payload("kernel_initramfs", args, {"kver": kver}), + ) + if approval_error: + yield approval_error + return + proc = None + try: + proc = await asyncio.create_subprocess_exec( + "dracut", "--force", "--kver", kver, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + async for raw in proc.stdout: + yield {"line": _ANSI.sub("", raw.decode(errors="replace").rstrip())} + await proc.wait() + rc = proc.returncode + done_chunk: dict = {"done": True, "returncode": rc} + if rc == 0: + boot = _Path("/boot") + initramfs_found = ( + (boot / f"initramfs-{kver}.img").exists() + or (boot / f"initrd-{kver}.img").exists() + ) + done_chunk["initramfs_found"] = initramfs_found + yield done_chunk + finally: + await _terminate_subprocess(proc) + + +async def cmd_kernel_module_rebuild(args): + approval_error = await in_thread( + _require_approval, + "kernel_module_rebuild", + _approval_payload("kernel_module_rebuild", args, {}), + ) + if approval_error: + yield approval_error + return + async for item in _start_background_job( + "@module-rebuild", + ["emerge", "--verbose", "--color=n", "@module-rebuild"], + kind="module-rebuild", + action_cmd="kernel_module_rebuild", + action_args={}, + ): + yield item + + +async def cmd_kernel_download_tarball(args): + import re as _re, threading as _threading, urllib.request as _urllib + + url = str(args.get("url", "")).strip() + _ALLOWED_HOSTS = ("https://cdn.kernel.org/", "https://www.kernel.org/") + if not any(url.startswith(h) for h in _ALLOWED_HOSTS): + yield {"error": "URL must be from cdn.kernel.org or www.kernel.org"} + return + if not (url.endswith(".tar.xz") or url.endswith(".tar.gz")): + yield {"error": "URL must point to a .tar.xz or .tar.gz tarball"} + return + + filename = url.split("/")[-1] + if filename.endswith(".tar.xz"): + dirname = filename[:-7] + else: + dirname = filename[:-7] + if not _re.match(r"^linux-[\w.+-]+$", dirname): + yield {"error": f"unexpected tarball name: {filename}"} + return + + from pathlib import Path as _Path + src_base = _Path("/usr/src") + dest_dir = src_base / dirname + tarball_path = src_base / filename + + if dest_dir.exists(): + yield {"error": f"{dest_dir} already exists — remove it first or pick a different version"} + return + + approval_error = await in_thread( + _require_approval, + "kernel_download_tarball", + _approval_payload("kernel_download_tarball", args, {"url": url}), + ) + if approval_error: + yield approval_error + return + + yield {"line": f"WARNING: GPG signature verification skipped"} + yield {"line": f"Downloading {filename} from kernel.org…"} + + # url is pre-validated above: HTTPS + kernel.org allowlist + tarball extension only. + # file:// and arbitrary hosts are impossible here. + total = 0 + try: + req = _urllib.Request(url, method="HEAD") + with _urllib.urlopen(req, timeout=15) as r: # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected + total = int(r.headers.get("Content-Length", 0)) + except Exception: + pass + if total: + yield {"line": f"File size: {total // (1024 * 1024)} MB"} + + # Download in background thread, poll file size for progress. + # urlretrieve is deprecated; use urlopen with explicit streaming write. + done_evt = _threading.Event() + err_holder: list = [] + + def _dl(): + try: + req = _urllib.Request(url) + with _urllib.urlopen(req, timeout=300) as resp, open(str(tarball_path), "wb") as fout: # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected + while True: + chunk = resp.read(256 * 1024) + if not chunk: + break + fout.write(chunk) + except Exception as exc: + err_holder.append(str(exc)) + finally: + done_evt.set() + + t = _threading.Thread(target=_dl, daemon=True) + t.start() + + last_pct = -1 + while not done_evt.is_set(): + await asyncio.sleep(1) + try: + size = tarball_path.stat().st_size + if total: + pct = min(int(size * 100 / total), 99) + if pct != last_pct: + yield {"line": f" {pct}% ({size // (1024 * 1024)}/{total // (1024 * 1024)} MB)", "progress": size, "total": total} + last_pct = pct + else: + yield {"line": f" {size // (1024 * 1024)} MB downloaded…", "progress": size} + except OSError: + pass + + if err_holder: + try: + tarball_path.unlink(missing_ok=True) + except OSError: + pass + yield {"error": f"Download failed: {err_holder[0]}"} + return + + yield {"line": f"Download complete. Extracting to /usr/src/…"} + + proc = None + try: + proc = await asyncio.create_subprocess_exec( + "tar", "-xf", str(tarball_path), "-C", str(src_base), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + async for raw in proc.stdout: + line = raw.decode(errors="replace").rstrip() + if line: + yield {"line": line} + await proc.wait() + if proc.returncode != 0: + yield {"error": f"tar extraction failed (rc={proc.returncode})"} + return + finally: + await _terminate_subprocess(proc) + + # Remove tarball + try: + tarball_path.unlink(missing_ok=True) + yield {"line": f"Tarball removed."} + except OSError as exc: + yield {"line": f"Warning: could not remove tarball: {exc}"} + + # Update /usr/src/linux symlink + linux_link = src_base / "linux" + try: + if linux_link.is_symlink() or linux_link.exists(): + linux_link.unlink() + linux_link.symlink_to(dirname) + yield {"line": f"/usr/src/linux → {dirname}"} + except OSError as exc: + yield {"line": f"Warning: could not update /usr/src/linux symlink: {exc}"} + + yield {"line": f"Done. Sources ready in /usr/src/{dirname}"} + yield {"done": True, "returncode": 0, "dirname": dirname} + + +async def cmd_kernel_reboot(args): + approval_error = await in_thread( + _require_approval, + "kernel_reboot", + _approval_payload("kernel_reboot", args, {}), + ) + if approval_error: + yield approval_error + return + + yield {"line": "Scheduling reboot…"} + + proc = None + try: + proc = await asyncio.create_subprocess_exec( + "shutdown", "-r", "now", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + ) + async for raw in proc.stdout: + line = raw.decode(errors="replace").rstrip() + if line: + yield {"line": line} + await proc.wait() + yield {"done": True, "returncode": proc.returncode} + finally: + await _terminate_subprocess(proc) + + +async def cmd_kernel_switch_src(args): + import re as _re + dirname = str(args.get("dirname", "")).strip() + if not _re.match(r'^linux-[\w.+-]+$', dirname): + yield {"error": "invalid source directory name"} + return + from pathlib import Path as _Path + target = _Path("/usr/src") / dirname + if not target.is_dir(): + yield {"error": f"/usr/src/{dirname} is not a directory"} + return + + def _switch(): + link = _Path("/usr/src/linux") + if link.is_symlink() or link.exists(): + link.unlink() + link.symlink_to(dirname) + + await in_thread(_switch) + yield {"line": f"/usr/src/linux → {dirname}"} + yield {"done": True, "returncode": 0} + + +async def cmd_kernel_copy_config(args): + import re as _re, shutil as _shutil + src_dirname = str(args.get("src_dirname", "")).strip() + if not _re.match(r'^linux-[\w.+-]+$', src_dirname): + yield {"error": "invalid source directory name"} + return + from pathlib import Path as _Path + src_config = _Path("/usr/src") / src_dirname / ".config" + if not src_config.is_file(): + yield {"error": f"/usr/src/{src_dirname}/.config not found"} + return + dst_dir = _Path("/usr/src/linux").resolve() + if not dst_dir.is_dir(): + yield {"error": "/usr/src/linux does not resolve to a directory"} + return + dst_config = dst_dir / ".config" + + def _copy(): + _shutil.copy2(str(src_config), str(dst_config)) + + await in_thread(_copy) + yield {"line": f"Copied {src_config} → {dst_config}"} + yield {"done": True, "returncode": 0} + + +# --------------------------------------------------------------------------- +# Limine bootloader config +# --------------------------------------------------------------------------- + +def _parse_limine_conf(text: str) -> dict: + """Parse /boot/limine.conf into structured data.""" + import re as _re + lines = text.replace('\r\n', '\n').split('\n') + + # Split preamble (before first entry line starting with /) from body + preamble = [] + body = [] + in_body = False + for line in lines: + if not in_body and line.startswith('/'): + in_body = True + (body if in_body else preamble).append(line) + + global_raw = '\n'.join(preamble) + + def _extract_global(key): + m = _re.search(r'^' + _re.escape(key) + r':\s*(.*)$', global_raw, _re.MULTILINE) + return m.group(1).strip() if m else '' + + # Split body into entry blocks (new block starts at column-0 '/') + blocks = [] + cur = [] + for line in body: + if line.startswith('/') and cur: + while cur and not cur[-1].strip(): + cur.pop() + blocks.append(cur) + cur = [line] + else: + cur.append(line) + if cur: + while cur and not cur[-1].strip(): + cur.pop() + if cur: + blocks.append(cur) + + entries = [] + for block in blocks: + if not block: + continue + header = block[0] + group_name = header.lstrip('/+').strip() + sub_name = '' + keys = {} + extra = [] + has_sub = False + for line in block[1:]: + s = line.strip() + if not s: + continue + if line.lstrip().startswith('//'): + sub_name = line.lstrip().lstrip('/').strip() + has_sub = True + elif ':' in s and not s.startswith('#'): + k, _, v = s.partition(':') + k = k.strip(); v = v.strip() + if k in ('protocol', 'path', 'module_path', 'cmdline'): + keys[k] = v + else: + extra.append(line) + else: + extra.append(line) + if has_sub and keys.get('protocol') == 'linux': + entries.append({ + 'type': 'linux', + 'group_name': group_name, + 'sub_name': sub_name, + 'path': keys.get('path', ''), + 'module_path': keys.get('module_path', ''), + 'cmdline': keys.get('cmdline', ''), + }) + else: + entries.append({'type': 'raw', 'raw': '\n'.join(block)}) + + return { + 'global_raw': global_raw, + 'timeout': _extract_global('timeout'), + 'default_entry': _extract_global('default_entry'), + 'remember_last_entry': _extract_global('remember_last_entry'), + 'entries': entries, + } + + +def _serialize_limine_conf(data: dict) -> str: + """Serialize structured config back to limine.conf text.""" + import re as _re + global_raw = data.get('global_raw', '') + + def _replace_setting(text, key, value): + pat = _re.compile(r'^(' + _re.escape(key) + r':\s*)(.*)$', _re.MULTILINE) + if pat.search(text): + return pat.sub(r'\g<1>' + str(value), text) + return key + ': ' + str(value) + '\n' + text + + global_raw = _replace_setting(global_raw, 'timeout', data.get('timeout', '5')) + global_raw = _replace_setting(global_raw, 'default_entry', data.get('default_entry', '1')) + rl = data.get('remember_last_entry', '') + if rl: + global_raw = _replace_setting(global_raw, 'remember_last_entry', rl) + + if not global_raw.endswith('\n'): + global_raw += '\n' + + parts = [global_raw] + for entry in data.get('entries', []): + if entry.get('type') == 'linux': + b = f"/+{entry['group_name']}\n" + if entry.get('sub_name'): + b += f" //{entry['sub_name']}\n" + b += f" protocol: linux\n" + b += f" path: {entry.get('path', '')}\n" + if entry.get('module_path'): + b += f" module_path: {entry['module_path']}\n" + if entry.get('cmdline'): + b += f" cmdline: {entry['cmdline']}\n" + parts.append(b) + elif entry.get('type') == 'raw': + parts.append(entry.get('raw', '')) + + result = '' + for i, part in enumerate(parts): + if i == 0: + result = part + else: + if result and not result.endswith('\n\n'): + result = result.rstrip('\n') + '\n\n' + result += part + if not result.endswith('\n'): + result += '\n' + return result + + +async def cmd_limine_config_read(_args): + from pathlib import Path as _Path + conf = _Path("/boot/limine.conf") + if not conf.is_file(): + yield {"error": "/boot/limine.conf not found"} + return + try: + text = conf.read_text(errors="replace") + parsed = _parse_limine_conf(text) + yield parsed + except OSError as e: + yield {"error": str(e)} + + +async def cmd_limine_config_write(args): + import json as _json, shutil as _shutil, datetime as _dt + from pathlib import Path as _Path + + config_json = args.get("config_json", "") + if not config_json: + yield {"error": "config_json is required"} + return + try: + config_data = _json.loads(config_json) + except Exception as e: + yield {"error": f"invalid config_json: {e}"} + return + + approval_error = await in_thread( + _require_approval, + "limine_config_write", + _approval_payload("limine_config_write", args, {}), + ) + if approval_error: + yield approval_error + return + + conf_path = _Path("/boot/limine.conf") + + def _do_write(): + ts = _dt.datetime.now().strftime("%Y%m%d%H%M%S") + backup = conf_path.parent / f"limine.conf.bak.{ts}" + _shutil.copy2(str(conf_path), str(backup)) + new_text = _serialize_limine_conf(config_data) + conf_path.write_text(new_text) + return str(backup) + + backup = await in_thread(_do_write) + yield {"done": True, "returncode": 0, "backup": backup} + + +async def cmd_limine_config_auto_update(args): + """Clone the currently-running kernel's Limine entry and point it at kver.""" + import copy as _copy, os as _os, shutil as _shutil + import datetime as _dt + from pathlib import Path as _Path + + kver = str(args.get("kver", "")).strip() + if not kver: + yield {"error": "kver is required"} + return + + approval_error = await in_thread( + _require_approval, + "limine_config_auto_update", + _approval_payload("limine_config_auto_update", args, {"kver": kver}), + ) + if approval_error: + yield approval_error + return + + def _do(): + conf_path = _Path("/boot/limine.conf") + if not conf_path.is_file(): + return {"error": "limine.conf not found"} + + text = conf_path.read_text(errors="replace") + data = _parse_limine_conf(text) + + running_kver = _os.uname().release + linux_entries = [e for e in data["entries"] if e.get("type") == "linux"] + + if not linux_entries: + return {"error": "no linux entries found in limine.conf"} + + # Find the entry matching the running kernel; fall back to first linux entry + source_entry = None + for e in linux_entries: + if running_kver in e.get("path", ""): + source_entry = e + break + if source_entry is None: + source_entry = linux_entries[0] + + def _subst(s): + if running_kver and running_kver != kver and running_kver in s: + return s.replace(running_kver, kver) + return s + + new_entry = _copy.deepcopy(source_entry) + src_path = source_entry.get("path", "") + new_entry["path"] = _subst(src_path) if src_path else f"boot():/vmlinuz-{kver}" + src_mp = source_entry.get("module_path", "") + new_entry["module_path"] = _subst(src_mp) if src_mp else f"boot():/initramfs-{kver}.img" + new_entry["group_name"] = _subst(source_entry.get("group_name", f"Linux {kver}")) + + # Replace existing entry for kver in-place, or insert before first linux entry + target_idx = None + for i, e in enumerate(data["entries"]): + if e.get("type") == "linux" and kver in e.get("path", ""): + target_idx = i + break + + if target_idx is not None: + data["entries"][target_idx] = new_entry + new_entry_pos = target_idx + 1 # 1-based + else: + first_linux = next((i for i, e in enumerate(data["entries"]) if e.get("type") == "linux"), 0) + data["entries"].insert(first_linux, new_entry) + new_entry_pos = first_linux + 1 # 1-based + + # Only update default_entry; leave timeout and remember_last_entry + # untouched so the user can still pick a different kernel from the menu. + data["default_entry"] = str(new_entry_pos) + + ts = _dt.datetime.now().strftime("%Y%m%d%H%M%S") + backup = conf_path.parent / f"limine.conf.bak.{ts}" + _shutil.copy2(str(conf_path), str(backup)) + conf_path.write_text(_serialize_limine_conf(data)) + return { + "done": True, "returncode": 0, + "backup": str(backup), + "kver": kver, + "source_kver": running_kver, + "default_entry": new_entry_pos, + } + + result = await in_thread(_do) + yield result + + async def cmd_emerge_sync(_args): approval_error = await in_thread(_require_approval, "emerge_sync", _approval_payload("emerge_sync", _args)) if approval_error: @@ -3731,6 +5064,31 @@ async def cmd_snapshot_import(args): "eclean_run": cmd_eclean_run, "snapshot_export": cmd_snapshot_export, "snapshot_import": cmd_snapshot_import, + "revdep_rebuild_pretend": cmd_revdep_rebuild_pretend, + "revdep_rebuild": cmd_revdep_rebuild, + "disk_usage": cmd_disk_usage, + "kernel_status": cmd_kernel_status, + "kernel_available": cmd_kernel_available, + "kernel_install_pretend": cmd_kernel_install_pretend, + "kernel_install": cmd_kernel_install, + "kernel_bootloader_update": cmd_kernel_bootloader_update, + "kernel_boot_clean": cmd_kernel_boot_clean, + "kernel_modules_clean": cmd_kernel_modules_clean, + "kernel_src_clean": cmd_kernel_src_clean, + "kernel_oldconfig": cmd_kernel_oldconfig, + "kernel_olddefconfig": cmd_kernel_olddefconfig, + "kernel_build": cmd_kernel_build, + "kernel_modules_install": cmd_kernel_modules_install, + "kernel_make_install": cmd_kernel_make_install, + "kernel_initramfs": cmd_kernel_initramfs, + "kernel_module_rebuild": cmd_kernel_module_rebuild, + "kernel_download_tarball": cmd_kernel_download_tarball, + "kernel_reboot": cmd_kernel_reboot, + "kernel_switch_src": cmd_kernel_switch_src, + "kernel_copy_config": cmd_kernel_copy_config, + "limine_config_read": cmd_limine_config_read, + "limine_config_write": cmd_limine_config_write, + "limine_config_auto_update": cmd_limine_config_auto_update, } diff --git a/frontend/alpine/app.js b/frontend/alpine/app.js index 10011d5..cb85335 100644 --- a/frontend/alpine/app.js +++ b/frontend/alpine/app.js @@ -105,6 +105,15 @@ await _post('/auth/step-up/ensure', {}) } + function _fmtBytes(b) { + if (!b) return '0 B' + const gb = b / 1024 ** 3 + if (gb >= 1) return gb.toFixed(1) + ' GB' + const mb = b / 1024 ** 2 + if (mb >= 1) return mb.toFixed(0) + ' MB' + return (b / 1024).toFixed(0) + ' KB' + } + const api = { authBackend: () => _get('/auth/backend'), authSession: () => _get('/auth/session'), @@ -131,6 +140,10 @@ newsRead: (id) => _post('/news/read', { id }), newsReadAll: () => _post('/news/read-all', {}), glsa: () => _get('/glsa'), + diskUsage: () => _get('/storage-stats'), + kernelStatus: () => _get('/kernel/status'), + kernelAvailable: () => _get('/kernel/available'), + kernelOrgReleases:() => _get('/kernel/org-releases'), snapshotImport: (file) => { const fd = new FormData(); fd.append('file', file) return fetch('/api/snapshot/import', { method: 'POST', credentials: 'same-origin', headers: { 'X-CSRF-Token': _csrfToken() }, body: fd }) @@ -249,7 +262,7 @@ // and read-only attaches do not. const _MUTATING_EMERGE_CMDS = new Set([ 'install', 'uninstall', 'autounmask', - 'world-update', 'depclean', 'preserved-rebuild', 'sync', 'eclean', + 'world-update', 'depclean', 'preserved-rebuild', 'sync', 'eclean', 'revdep-rebuild', ]) function wsEmerge(cmd, atom, onMsg, extra = {}) { @@ -268,6 +281,14 @@ ) } + function wsKernel(cmd, onMsg, extra = {}) { + return _openAuthedWebSocket( + _withQuery('/ws/kernel/' + cmd, extra), + onMsg, + { requiresStepUp: cmd !== 'install-pretend' }, + ) + } + function wsJobAttach(jobId, onMsg) { return _openAuthedWebSocket('/ws/jobs/' + encodeURIComponent(jobId), onMsg) } @@ -350,7 +371,7 @@ if (request.action_cmd === 'emerge_uninstall' && args.atom) { return { view: 'uninstall', param: _approvalAtomKey(args.atom) } } - if (['emerge_world_update', 'emerge_depclean', 'emerge_preserved_rebuild', 'emerge_sync'].includes(request.action_cmd)) { + if (['emerge_world_update', 'emerge_depclean', 'emerge_preserved_rebuild', 'emerge_sync', 'revdep_rebuild'].includes(request.action_cmd)) { return { view: 'updates', param: null } } if (['overlay_sync', 'overlay_add', 'overlay_remove'].includes(request.action_cmd)) { @@ -762,6 +783,7 @@ { id: 'updates', label: 'Maintenance' }, { id: 'overlays', label: 'Overlays' }, { id: 'snapshot', label: 'Snapshot' }, + { id: 'kernel', label: 'Kernel (beta)' }, { id: 'news', label: 'News' }, { id: 'glsa', label: 'Advisories' }, { id: 'jobs', label: 'Jobs' }, @@ -824,6 +846,14 @@ if (approvalPending(request)) _navigateToApprovalRequest(request) }) }, + openNavGroup: '', + toggleNavGroup(id) { this.openNavGroup = this.openNavGroup === id ? '' : id }, + closeNavGroup() { this.openNavGroup = '' }, + navTo(id) { this.closeNavGroup(); this.nav(id) }, + isNavGroupActive(id) { + const _g = { packages: ['packages','search','use-flags'], system: ['updates','overlays','snapshot','kernel'] } + return (_g[id] || []).indexOf(Alpine.store('router').view) !== -1 + }, isActive(id) { return isPrimaryRouteActive(id) }, nav(id) { if (Alpine.store('approvalGate').active) return @@ -1029,6 +1059,7 @@ } } if (r.view === 'snapshot') return { section: 'Config', title: 'Snapshot', detail: 'Export or import /etc/portage configuration' } + if (r.view === 'kernel') return { section: 'System', title: 'Kernel', detail: 'Installed kernels, available updates, /boot inventory and bootloader info' } if (r.view === 'install') { return { section: 'Packages', @@ -1054,6 +1085,7 @@ { id: 'updates-check', label: 'Check' }, { id: 'updates-world', label: '@world' }, { id: 'updates-preserved', label: 'Preserved' }, + { id: 'updates-revdep', label: 'Revdep' }, { id: 'updates-depclean', label: 'Depclean' }, { id: 'updates-clean', label: 'Clean' }, ] @@ -1212,6 +1244,7 @@ recentHistory: [], newsData: null, glsaData: null, + diskUsage: null, error: null, _timer: null, init() { @@ -1230,7 +1263,7 @@ async _load() { try { this.error = null - const [statusRes, statsRes, pkgStatsRes, compileCatsRes, jobsRes, historyRes, newsRes, glsaRes] = await Promise.allSettled([ + const [statusRes, statsRes, pkgStatsRes, compileCatsRes, jobsRes, historyRes, newsRes, glsaRes, diskUsageRes] = await Promise.allSettled([ api.status(), api.stats(), api.pkgStats(), @@ -1239,6 +1272,7 @@ jobHistory.list(12, 0, ''), api.news(), api.glsa(), + api.diskUsage(), ]) if (statusRes.status !== 'fulfilled') throw statusRes.reason this.status = statusRes.value @@ -1249,6 +1283,7 @@ this.recentHistory = historyRes.status === 'fulfilled' && Array.isArray(historyRes.value?.items) ? historyRes.value.items : [] this.newsData = newsRes.status === 'fulfilled' ? newsRes.value : null this.glsaData = glsaRes.status === 'fulfilled' ? glsaRes.value : null + this.diskUsage = diskUsageRes.status === 'fulfilled' ? diskUsageRes.value : null } catch(e) { this.error = e.message } finally { this.$nextTick(() => this._renderCompileCats()) } }, @@ -1505,6 +1540,20 @@ tone: 'muted', }, ] + // Disk usage (distfiles + binpkgs + tmp) + if (this.diskUsage) { + const du = this.diskUsage + const dist = du.distfiles || 0 + const bin = du.binpkgs || 0 + const tmp = du.tmp_portage || 0 + items.push({ + key: 'disk-usage', + label: 'Portage cache', + value: _fmtBytes(dist + bin + tmp), + detail: 'distfiles ' + _fmtBytes(dist) + ' · binpkgs ' + _fmtBytes(bin) + ' · tmp ' + _fmtBytes(tmp), + tone: 'muted', + }) + } // Unread news const unreadNews = Array.isArray(this.newsData) ? this.newsData.filter(n => n.unread).length : null if (unreadNews !== null) { @@ -3599,6 +3648,16 @@ ${labels} 'This is typically follow-up work after updates changed linked libraries.', ], }, + revdep: { + id: 'revdep', + title: 'Revdep rebuild', + summary: 'Scan and rebuild packages with broken library dependencies.', + risk: 'Low. Only rebuilds packages that reference missing or changed shared libraries.', + notes: [ + 'Run the pretend phase first to see which packages will be rebuilt.', + 'Useful after library upgrades that left dependent packages with broken linkage.', + ], + }, depclean: { id: 'depclean', title: 'Depclean', @@ -3630,6 +3689,7 @@ ${labels} worldUpdate: mkOp(false), depclean: { ...mkOp(false), dcStep: 'idle' }, preserved: mkOp(false), + revdep: mkOp(false), clean: { ...mkOp(false), cleanStep: 'idle', cleanTarget: 'dist' }, pkgStats: null, selectedAction: 'worldPretend', @@ -3704,6 +3764,7 @@ ${labels} if (id === 'worldPretend') return 'emerge -uDN --pretend @world' if (id === 'sync') return 'emaint sync -a' if (id === 'preserved') return 'emerge @preserved-rebuild' + if (id === 'revdep') return 'revdep-rebuild' if (id === 'depclean') return 'emerge --depclean' if (id === 'clean') return this.clean.cleanTarget === 'dist' ? 'eclean-dist' : 'eclean-pkg' return '' @@ -3810,6 +3871,7 @@ ${labels} if (cmd === 'emerge_world_update') return this._startWorldUpdate() if (cmd === 'emerge_depclean') return this._startDepclean() if (cmd === 'emerge_preserved_rebuild') return this._startPreserved() + if (cmd === 'revdep_rebuild') return this._startRevdep() }) op.expanded = true op.running = false @@ -3837,6 +3899,10 @@ ${labels} this.selectAction('preserved') op = this.preserved onApproved = () => this._startPreserved() + } else if (request.action_cmd === 'revdep_rebuild') { + this.selectAction('revdep') + op = this.revdep + onApproved = () => this._startRevdep() } else { return false } @@ -4049,6 +4115,37 @@ ${labels} if (msg.done) { this.preserved.running = false; this.preserved.rc = msg.returncode ?? null; this.preserved.ws = null; _scopedStorageRemove(_JOB_META.preserved.storage) } }) }, + startRevdepPretend() { + this.selectAction('revdep') + this.revdep.lines = []; this.revdep.rc = null; this.revdep.running = true; this.revdep.expanded = true + this.revdep.ws = wsGlobalEmerge('revdep-pretend', (msg) => { + if (msg.job_id) return + if (msg.line !== undefined) this._appendLine(this.revdep, 'revdepTerm', msg.line) + if (msg.done) { this.revdep.running = false; this.revdep.rc = msg.returncode ?? null; this.revdep.ws = null } + }) + }, + startRevdep() { + this.selectAction('revdep') + this._startRevdep() + }, + async _startRevdep() { + this.revdep.lines = []; this.revdep.rc = null; this.revdep.running = true; this.revdep.expanded = true + let approval + try { + approval = await this._requestOpApproval(this.revdep, 'revdep_rebuild', {}) + } catch (e) { + this.revdep.lines = ['Error: ' + e.message] + return + } + if (!approval) return + clearApprovalState(this.revdep) + this.revdep.running = true + this.revdep.ws = wsGlobalEmerge('revdep-rebuild', (msg) => { + if (msg.job_id) return + if (msg.line !== undefined) this._appendLine(this.revdep, 'revdepTerm', msg.line) + if (msg.done) { this.revdep.running = false; this.revdep.rc = msg.returncode ?? null; this.revdep.ws = null } + }, { approval_request_id: approval.request_id }) + }, } } @@ -4236,6 +4333,7 @@ ${labels} Alpine.data('newsComponent', newsComponent) Alpine.data('glsaComponent', glsaComponent) Alpine.data('snapshotComponent', snapshotComponent) + Alpine.data('kernelComponent', kernelComponent) }) // --------------------------------------------------------------------------- @@ -4541,6 +4639,921 @@ ${labels} } } + const _mkKernelOp = () => ({ lines: [], running: false, rc: null, ws: null, approvalRequest: null, approvalCommand: '', _approvalPollTimer: null }) + + function kernelComponent() { + return { + status: null, + available: [], + orgReleases: null, + loading: true, + error: null, + activeTab: 'overview', + open: { status: false, installed: false, boot: false, bootloaders: false, srcdirs: false, portage: false, upstream: false, limine: false }, + switchSrc: { op: _mkKernelOp(), dirname: null }, + pretend: _mkKernelOp(), + install: _mkKernelOp(), + selectedAtom: null, + bootloader: { op: _mkKernelOp(), name: null }, + cleanup: { selected: {}, op: _mkKernelOp() }, + modulesCleanup: { selected: {}, op: _mkKernelOp() }, + srcCleanup: { selected: {}, op: _mkKernelOp() }, + download: { op: _mkKernelOp(), version: null, progress: 0, total: 0 }, + manualFlow: { + step: 0, + kver: '', + oldconfig: _mkKernelOp(), + olddefconfig: _mkKernelOp(), + build: _mkKernelOp(), + modulesInstall: _mkKernelOp(), + makeInstall: _mkKernelOp(), + initramfs: Object.assign(_mkKernelOp(), { initramfs_found: null }), + moduleRebuild: _mkKernelOp(), + limineAutoUpdate: _mkKernelOp(), + }, + reboot: { op: _mkKernelOp(), confirmText: '' }, + copyConfig: { op: _mkKernelOp(), srcDirname: '' }, + limine: { + loading: false, + error: null, + config: null, + edited: null, + dirty: false, + saveOp: _mkKernelOp(), + showPreview: false, + newEntry: null, + }, + async init() { await this.load(); this._restoreWizardState(); await this._restoreKernelJobs(); if (this.limineHasLimine()) void this.limineLoad() }, + async load() { + this.loading = true; this.error = null + const [s, a, o] = await Promise.allSettled([ + api.kernelStatus(), + api.kernelAvailable(), + api.kernelOrgReleases(), + ]) + if (s.status === 'fulfilled' && s.value && !s.value.error) { + this.status = s.value + } else { + this.error = (s.value && s.value.error) || 'Failed to load kernel status' + } + if (a.status === 'fulfilled') this.available = Array.isArray(a.value) ? a.value : [] + if (o.status === 'fulfilled' && o.value && !o.value.error) this.orgReleases = o.value + this.loading = false + }, + _saveWizardState() { + if (!this.manualFlow.step) return + try { + _scopedStorageSet('kw_wizard', JSON.stringify({ + kver: this.manualFlow.kver, + build_rc: this.manualFlow.build.rc, + modules_install_rc: this.manualFlow.modulesInstall.rc, + make_install_rc: this.manualFlow.makeInstall.rc, + initramfs_rc: this.manualFlow.initramfs.rc, + module_rebuild_rc: this.manualFlow.moduleRebuild.rc, + })) + } catch(e) {} + }, + _restoreWizardState() { + try { + var raw = _scopedStorageGet('kw_wizard') + if (!raw) return + var saved = JSON.parse(raw) + if (!saved || !saved.kver) return + this.manualFlow.step = 1 + this.manualFlow.kver = saved.kver + if (saved.build_rc !== null && saved.build_rc !== undefined) this.manualFlow.build.rc = saved.build_rc + if (saved.modules_install_rc !== null && saved.modules_install_rc !== undefined) this.manualFlow.modulesInstall.rc = saved.modules_install_rc + if (saved.make_install_rc !== null && saved.make_install_rc !== undefined) this.manualFlow.makeInstall.rc = saved.make_install_rc + if (saved.initramfs_rc !== null && saved.initramfs_rc !== undefined) this.manualFlow.initramfs.rc = saved.initramfs_rc + if (saved.module_rebuild_rc !== null && saved.module_rebuild_rc !== undefined) this.manualFlow.moduleRebuild.rc = saved.module_rebuild_rc + } catch(e) {} + }, + _clearWizardState() { + try { _scopedStorageRemove('kw_wizard') } catch(e) {} + }, + async _restoreKernelJobs() { + let allJobs + try { allJobs = await jobs.list() } catch(e) { return } + if (!Array.isArray(allJobs)) return + const KERNEL_CMDS = { + kernel_build: true, kernel_modules_install: true, kernel_make_install: true, + kernel_module_rebuild: true, kernel_install: true, + } + const running = allJobs.filter(function(j) { return j.status === 'running' && KERNEL_CMDS[j.action_cmd] }) + if (!running.length) return + const self = this + running.forEach(function(job) { + if (job.action_cmd === 'kernel_build') { + if (self.manualFlow.step === 0) { + self.manualFlow.step = 1 + self.manualFlow.kver = self.kverFromSrcTarget(self.status && self.status.src_target) + } + const op = self.manualFlow.build + if (op.running) return + op.running = true; op.lines = [] + op.ws = wsJobAttach(job.job_id, function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-build', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self._saveWizardState() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }) + } else if (job.action_cmd === 'kernel_modules_install') { + if (self.manualFlow.step === 0) { + self.manualFlow.step = 1 + self.manualFlow.kver = self.kverFromSrcTarget(self.status && self.status.src_target) + } + const op = self.manualFlow.modulesInstall + if (op.running) return + op.running = true; op.lines = [] + op.ws = wsJobAttach(job.job_id, function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-modules-install', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self._saveWizardState() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }) + } else if (job.action_cmd === 'kernel_make_install') { + if (self.manualFlow.step === 0) { + self.manualFlow.step = 1 + self.manualFlow.kver = self.kverFromSrcTarget(self.status && self.status.src_target) + } + const op = self.manualFlow.makeInstall + if (op.running) return + op.running = true; op.lines = [] + op.ws = wsJobAttach(job.job_id, function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-make-install', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self._saveWizardState() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }) + } else if (job.action_cmd === 'kernel_module_rebuild') { + if (self.manualFlow.step === 0) { + self.manualFlow.step = 1 + self.manualFlow.kver = self.kverFromSrcTarget(self.status && self.status.src_target) + } + const op = self.manualFlow.moduleRebuild + if (op.running) return + op.running = true; op.lines = [] + op.ws = wsJobAttach(job.job_id, function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-modules', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self._saveWizardState() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }) + } else if (job.action_cmd === 'kernel_install') { + const op = self.install + if (op.running) return + const atom = job.atom || '' + self.selectedAtom = atom + self.open.portage = true + op.running = true; op.lines = [] + op.ws = wsJobAttach(job.job_id, function(msg) { + if (msg.line !== undefined) self._appendLine(op, msg.line) + if (msg.done) { + op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null + if (msg.returncode === 0 && self.isSourcesKernel(atom)) self.initManualFlow() + } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }) + } + }) + }, + toggle(key) { this.open[key] = !this.open[key] }, + fmtSize(bytes) { return _fmtBytes(bytes) }, + kernelType(pf) { + if (!pf) return '' + if (pf.includes('kernel-bin')) return 'dist-bin' + if (pf.includes('gentoo-kernel')) return 'dist' + if (pf.includes('-sources')) return 'sources' + return 'other' + }, + orgReleaseDate(r) { + if (!r || !r.released) return '' + const rel = r.released + if (typeof rel === 'string') return rel + if (rel && typeof rel === 'object') { + if (rel.isoformat) return rel.isoformat + if (rel.date) return rel.date + if (typeof rel.timestamp === 'number') + return new Date(rel.timestamp * 1000).toISOString().slice(0, 10) + } + return '' + }, + _appendLineTo(op, elemId, line) { + op.lines.push(line) + if (op.lines.length > 2000) op.lines.splice(0, op.lines.length - 2000) + this.$nextTick(function() { const el = document.getElementById(elemId); if (el) el.scrollTop = el.scrollHeight }) + }, + _appendLine(op, line) { + this._appendLineTo(op, 'kw-term', line) + }, + isSourcesKernel(cpv) { + return cpv && cpv.includes('-sources/') + }, + kverFromSrcTarget(src_target) { + if (!src_target) return '' + return src_target.replace(/^linux-/, '') + }, + showManualFlow() { + return this.manualFlow.step > 0 || (this.install.rc === 0 && this.selectedAtom && this.isSourcesKernel(this.selectedAtom)) + }, + initManualFlow() { + const kver = this.kverFromSrcTarget(this.status && this.status.src_target) + this.manualFlow.kver = kver + this.manualFlow.step = 1 + this.manualFlow.oldconfig = _mkKernelOp() + this.manualFlow.olddefconfig = _mkKernelOp() + this.manualFlow.build = _mkKernelOp() + this.manualFlow.modulesInstall = _mkKernelOp() + this.manualFlow.makeInstall = _mkKernelOp() + this.manualFlow.initramfs = Object.assign(_mkKernelOp(), { initramfs_found: null }) + this.manualFlow.moduleRebuild = _mkKernelOp() + this.manualFlow.limineAutoUpdate = _mkKernelOp() + this._clearWizardState() + }, + srcDirs() { + return this.status && Array.isArray(this.status.src_dirs) ? this.status.src_dirs : [] + }, + configSources() { + return this.srcDirs().filter(function(s) { return !s.active && s.has_config }) + }, + startCopyConfig() { + const dirname = this.copyConfig.srcDirname + if (!dirname) return + const op = this.copyConfig.op + const self = this + op.lines = []; op.rc = null; op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('copy-config', function(msg) { + if (msg.line !== undefined) op.lines.push(msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { src_dirname: dirname }) + }, + selectSource(src) { + this.manualFlow.kver = src.real_kver || src.kver + this.manualFlow.step = 1 + this.manualFlow.oldconfig = _mkKernelOp() + this.manualFlow.olddefconfig = _mkKernelOp() + this.manualFlow.build = Object.assign(_mkKernelOp(), { rc: src.built ? 0 : null }) + this.manualFlow.modulesInstall = Object.assign(_mkKernelOp(), { rc: src.modules_installed ? 0 : null }) + this.manualFlow.makeInstall = Object.assign(_mkKernelOp(), { rc: src.kernel_in_boot ? 0 : null }) + this.manualFlow.initramfs = Object.assign(_mkKernelOp(), { initramfs_found: null, rc: src.initramfs_in_boot ? 0 : null }) + this.manualFlow.moduleRebuild = _mkKernelOp() + this.manualFlow.limineAutoUpdate = _mkKernelOp() + this._clearWizardState() + this.activeTab = 'build' + }, + resetManualFlow() { + this.manualFlow.step = 0 + this.manualFlow.kver = '' + this.manualFlow.oldconfig = _mkKernelOp() + this.manualFlow.olddefconfig = _mkKernelOp() + this.manualFlow.build = _mkKernelOp() + this.manualFlow.modulesInstall = _mkKernelOp() + this.manualFlow.makeInstall = _mkKernelOp() + this.manualFlow.initramfs = Object.assign(_mkKernelOp(), { initramfs_found: null }) + this.manualFlow.moduleRebuild = _mkKernelOp() + this.manualFlow.limineAutoUpdate = _mkKernelOp() + this._clearWizardState() + }, + startSwitchSrc(dirname) { + const op = this.switchSrc.op + const self = this + this.switchSrc.dirname = dirname + op.lines = []; op.rc = null; op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('switch-src', function(msg) { + if (msg.line !== undefined) op.lines.push(msg.line) + if (msg.done) { + op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null + self.load() + } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { dirname: dirname }) + }, + selectAtom(cpv) { + if (this.selectedAtom === cpv) return + this.selectedAtom = cpv + this.pretend = _mkKernelOp() + this.install = _mkKernelOp() + }, + startKernelPretend(cpv) { + this.selectAtom(cpv) + this.open.portage = true + const op = this.pretend + op.lines = []; op.rc = null; op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('install-pretend', (msg) => { + if (msg.line !== undefined) this._appendLine(op, msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode ?? null; op.ws = null } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { atom: cpv }) + }, + async startKernelInstall(cpv) { + this.selectAtom(cpv) + this.open.portage = true + const op = this.install + op.lines = []; op.rc = null; op.running = true + const self = this + const doInstall = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('install', function(msg) { + if (msg.job_id) return + if (msg.line !== undefined) self._appendLine(op, msg.line) + if (msg.done) { + op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null + if (msg.returncode === 0 && self.isSourcesKernel(cpv)) self.initManualFlow() + } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { atom: cpv, approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_install', { atom: cpv }, + function(lines) { op.lines = lines }, + function() { doInstall(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false + return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doInstall(approval.request_id) + }, + async startBootloaderUpdate(name) { + const op = this.bootloader.op + this.bootloader.name = name + op.lines = []; op.rc = null; op.running = true + const self = this + const doUpdate = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('bootloader-update', function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-bl-term', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { bootloader_name: name, approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_bootloader_update', { bootloader_name: name }, + function(lines) { op.lines = lines }, + function() { doUpdate(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false + return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doUpdate(approval.request_id) + }, + toggleCleanup(ver) { + this.cleanup.selected[ver] = !this.cleanup.selected[ver] + }, + async startBootClean() { + const vers = Object.keys(this.cleanup.selected).filter(function(v) { return this.cleanup.selected[v] }, this) + if (!vers.length) return + const op = this.cleanup.op + op.lines = []; op.rc = null; op.running = true + const self = this + const doClean = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('boot-clean', function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-clean-term', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self.load() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { versions: vers.join(','), approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_boot_clean', { versions: vers.join(',') }, + function(lines) { op.lines = lines }, + function() { doClean(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false + return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doClean(approval.request_id) + }, + toggleSrcCleanup(name) { + this.srcCleanup.selected[name] = !this.srcCleanup.selected[name] + }, + async startSrcClean() { + const names = Object.keys(this.srcCleanup.selected).filter(function(n) { return this.srcCleanup.selected[n] }, this) + if (!names.length) return + const op = this.srcCleanup.op + op.lines = []; op.rc = null; op.running = true + const self = this + const doClean = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('src-clean', function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-src-clean-term', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self.load() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { names: names.join(','), approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_src_clean', { names: names.join(',') }, + function(lines) { op.lines = lines }, + function() { doClean(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false + return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doClean(approval.request_id) + }, + toggleModulesCleanup(ver) { + this.modulesCleanup.selected[ver] = !this.modulesCleanup.selected[ver] + }, + async startModulesClean() { + const vers = Object.keys(this.modulesCleanup.selected).filter(function(v) { return this.modulesCleanup.selected[v] }, this) + if (!vers.length) return + const op = this.modulesCleanup.op + op.lines = []; op.rc = null; op.running = true + const self = this + const doClean = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('modules-clean', function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-modules-clean-term', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self.load() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { versions: vers.join(','), approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_modules_clean', { versions: vers.join(',') }, + function(lines) { op.lines = lines }, + function() { doClean(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false + return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doClean(approval.request_id) + }, + async startKernelDownload(url, version) { + const op = this.download.op + const self = this + this.download.version = version + this.download.progress = 0 + this.download.total = 0 + op.lines = []; op.rc = null; op.running = true + this.open.upstream = true + const doDownload = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('download-tarball', function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-dl-term', msg.line) + if (msg.progress !== undefined) self.download.progress = msg.progress + if (msg.total !== undefined) self.download.total = msg.total + if (msg.done) { + op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null + if (msg.returncode === 0) { + self.load() + self.manualFlow.kver = version + self.manualFlow.step = 1 + } + } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { url: url, approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_download_tarball', { url: url }, + function(lines) { op.lines = lines }, + function() { doDownload(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doDownload(approval.request_id) + }, + startOldconfig() { + const op = this.manualFlow.oldconfig + const self = this + op.lines = []; op.rc = null; op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('oldconfig', function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-oldconfig', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }) + }, + async startOlddefconfig() { + const op = this.manualFlow.olddefconfig + const self = this + op.lines = []; op.rc = null; op.running = true + const doOlddefconfig = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('olddefconfig', function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-olddefconfig', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_olddefconfig', {}, + function(lines) { op.lines = lines }, + function() { doOlddefconfig(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doOlddefconfig(approval.request_id) + }, + async startBuild() { + const op = this.manualFlow.build + op.lines = []; op.rc = null; op.running = true + const self = this + const doBuild = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('build', function(msg) { + if (msg.job_id) return + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-build', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self._saveWizardState() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_build', {}, + function(lines) { op.lines = lines }, + function() { doBuild(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doBuild(approval.request_id) + }, + async startModulesInstall() { + const op = this.manualFlow.modulesInstall + op.lines = []; op.rc = null; op.running = true + const self = this + const doIt = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('modules-install', function(msg) { + if (msg.job_id) return + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-modules-install', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self._saveWizardState() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_modules_install', {}, + function(lines) { op.lines = lines }, + function() { doIt(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doIt(approval.request_id) + }, + async startMakeInstall() { + const op = this.manualFlow.makeInstall + op.lines = []; op.rc = null; op.running = true + const self = this + const doIt = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('make-install', function(msg) { + if (msg.job_id) return + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-make-install', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self._saveWizardState() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_make_install', {}, + function(lines) { op.lines = lines }, + function() { doIt(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doIt(approval.request_id) + }, + async startInitramfs() { + const kver = this.manualFlow.kver + const op = this.manualFlow.initramfs + op.lines = []; op.rc = null; op.running = true; op.initramfs_found = null + const self = this + const doInitramfs = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('initramfs', function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-initramfs', msg.line) + if (msg.done) { + op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null + if (msg.initramfs_found !== undefined) op.initramfs_found = msg.initramfs_found + self._saveWizardState() + } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { kver: kver, approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_initramfs', { kver: kver }, + function(lines) { op.lines = lines }, + function() { doInitramfs(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doInitramfs(approval.request_id) + }, + async startModuleRebuild() { + const op = this.manualFlow.moduleRebuild + op.lines = []; op.rc = null; op.running = true + const self = this + const doRebuild = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('module-rebuild', function(msg) { + if (msg.job_id) return + if (msg.line !== undefined) self._appendLineTo(op, 'kw-mf-modules', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null; self._saveWizardState() } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_module_rebuild', {}, + function(lines) { op.lines = lines }, + function() { doRebuild(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doRebuild(approval.request_id) + }, + async startReboot() { + if (this.reboot.confirmText !== 'REBOOT') return + const op = this.reboot.op + const self = this + op.lines = []; op.rc = null; op.running = true + const doReboot = function(approvalId) { + op.running = true + if (op.ws) { try { op.ws.close() } catch(_) {} } + op.ws = wsKernel('reboot', function(msg) { + if (msg.line !== undefined) self._appendLineTo(op, 'kw-reboot-term', msg.line) + if (msg.done) { op.running = false; op.rc = msg.returncode != null ? msg.returncode : null; op.ws = null } + if (msg.error) { op.running = false; op.lines.push('Error: ' + msg.error); op.ws = null } + }, { approval_request_id: approvalId }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'kernel_reboot', {}, + function(lines) { op.lines = lines }, + function() { doReboot(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doReboot(approval.request_id) + }, + limineHasLimine() { + if (!this.status || !Array.isArray(this.status.bootloaders)) return false + return this.status.bootloaders.some(function(b) { return b.name === 'limine' }) + }, + async limineLoad() { + if (!this.limineHasLimine()) return + this.limine.loading = true; this.limine.error = null + try { + const data = await _get('/limine/config') + this.limine.config = data + this.limine.edited = JSON.parse(JSON.stringify(data)) + this.limine.dirty = false + } catch(e) { + this.limine.error = e.message || 'Failed to load limine.conf' + } + this.limine.loading = false + }, + limineDefaultSelSync() { + var self = this + this.$nextTick(function() { + var sel = self.$refs.limineDefaultSel + if (!sel || !self.limine.edited) return + var idx = parseInt(self.limine.edited.default_entry, 10) - 1 + if (!isNaN(idx) && idx >= 0 && idx < sel.options.length) sel.selectedIndex = idx + }) + }, + limineDefaultEntryChanged(selectedIndex) { + this.limine.edited.default_entry = String(selectedIndex + 1) + this.limine.dirty = true + }, + limineEntries() { + if (!this.limine.edited) return [] + return this.limine.edited.entries || [] + }, + limineLinuxEntries() { + return this.limineEntries().filter(function(e) { return e.type === 'linux' }) + }, + limineMoveUp(idx) { + const entries = this.limine.edited.entries + if (idx < 1) return + const tmp = entries[idx - 1]; entries[idx - 1] = entries[idx]; entries[idx] = tmp + this.limine.dirty = true + this._limineFixDefaultEntry(idx, idx - 1) + }, + limineMoveDown(idx) { + const entries = this.limine.edited.entries + if (idx >= entries.length - 1) return + const tmp = entries[idx + 1]; entries[idx + 1] = entries[idx]; entries[idx] = tmp + this.limine.dirty = true + this._limineFixDefaultEntry(idx, idx + 1) + }, + _limineFixDefaultEntry(fromIdx, toIdx) { + const de = parseInt(this.limine.edited.default_entry, 10) + if (isNaN(de)) return + const from1 = fromIdx + 1; const to1 = toIdx + 1 + if (de === from1) this.limine.edited.default_entry = String(to1) + else if (de === to1) this.limine.edited.default_entry = String(from1) + }, + limineRemoveEntry(idx) { + this.limine.edited.entries.splice(idx, 1) + this.limine.dirty = true + }, + limineAddEntry() { + const entries = this.limine.edited.entries + // Insert before raw entries (EFI etc) — find last linux entry position + let insertAt = 0 + for (let i = entries.length - 1; i >= 0; i--) { + if (entries[i].type === 'linux') { insertAt = i + 1; break } + } + const newEntry = { + type: 'linux', + group_name: 'New Entry', + sub_name: 'New Entry', + path: 'boot():/vmlinuz-', + module_path: 'boot():/initramfs-.img', + cmdline: '', + } + entries.splice(insertAt, 0, newEntry) + this.limine.dirty = true + }, + liminePreviewText() { + if (!this.limine.edited) return '' + // Build preview manually (no access to Python serializer) + const d = this.limine.edited + let out = d.global_raw || '' + if (!out.endsWith('\n')) out += '\n' + const entries = d.entries || [] + for (let i = 0; i < entries.length; i++) { + const e = entries[i] + if (!out.endsWith('\n\n')) out = out.replace(/\n*$/, '\n\n') + if (e.type === 'linux') { + out += '/+' + e.group_name + '\n' + if (e.sub_name) out += ' //' + e.sub_name + '\n' + out += ' protocol: linux\n' + out += ' path: ' + e.path + '\n' + if (e.module_path) out += ' module_path: ' + e.module_path + '\n' + if (e.cmdline) out += ' cmdline: ' + e.cmdline + '\n' + } else if (e.type === 'raw') { + out += e.raw || '' + } + } + if (!out.endsWith('\n')) out += '\n' + return out + }, + async limineSave() { + if (!this.limine.edited) return + const op = this.limine.saveOp + const self = this + op.lines = []; op.rc = null; op.running = true + const configJson = JSON.stringify(this.limine.edited) + const doSave = function(approvalId) { + op.running = true + _post('/limine/config', { + config_json: configJson, + approval_request_id: approvalId || '', + approval_token: '', + }).then(function(result) { + op.running = false + op.rc = result.returncode != null ? result.returncode : (result.error ? 1 : 0) + if (result.error) { + op.lines = ['Error: ' + result.error] + } else { + op.lines = ['Saved. Backup: ' + (result.backup || '')] + self.limine.dirty = false + self.limine.showPreview = false + self.limineLoad() + } + }).catch(function(e) { + op.running = false + op.rc = 1 + op.lines = ['Error: ' + e.message] + }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'limine_config_write', {}, + function(lines) { op.lines = lines }, + function() { doSave(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doSave(approval.request_id) + }, + + async startLimineAutoUpdate() { + const kver = this.manualFlow.kver + if (!kver) return + const op = this.manualFlow.limineAutoUpdate + const self = this + op.lines = []; op.rc = null; op.running = true + const doUpdate = function(approvalId) { + op.running = true + _post('/limine/auto-update', { + kver: kver, + approval_request_id: approvalId || '', + approval_token: '', + }).then(function(result) { + op.running = false + op.rc = result.returncode != null ? result.returncode : (result.error ? 1 : 0) + if (result.error) { + op.lines = ['Error: ' + result.error] + } else { + op.lines = [ + 'Entry added for ' + result.kver + ' (entry #' + result.default_entry + ').', + 'Select it from the Limine menu on next boot.', + 'Backup: ' + (result.backup || ''), + ] + self._saveWizardState() + if (self.limineHasLimine()) void self.limineLoad() + } + }).catch(function(e) { + op.running = false + op.rc = 1 + op.lines = ['Error: ' + e.message] + }) + } + let approval + try { + approval = await _approvalRequestReady( + op, 'limine_config_auto_update', { kver: kver }, + function(lines) { op.lines = lines }, + function() { doUpdate(op.approvalRequest && op.approvalRequest.request_id) }, + ) + } catch(e) { + op.lines = ['Error: ' + e.message]; op.running = false; return + } + if (!approval) { op.running = false; return } + clearApprovalState(op) + doUpdate(approval.request_id) + }, + } + } + function snapshotComponent() { return { exportLoading: false, @@ -4583,7 +5596,7 @@ ${labels} Object.assign(window, { navigate, navigateTo, navigateToUse, navigateBack, api, emerge, jobs, jobHistory, overlays, - wsEmerge, wsGlobalEmerge, wsJobAttach, wsOverlaySync, detachWs, + wsEmerge, wsGlobalEmerge, wsKernel, wsJobAttach, wsOverlaySync, detachWs, }) }()) diff --git a/frontend/alpine/index.html b/frontend/alpine/index.html index 556c31e..17cc25a 100644 --- a/frontend/alpine/index.html +++ b/frontend/alpine/index.html @@ -145,18 +145,61 @@
Loading…
Loading USE flags…
+Explicit package.use state for this flag. These rows may include packages that are not installed.
Installed packages whose IUSE supports this flag and whose current build has it enabled.
Installed packages whose IUSE supports this flag but whose current build does not enable it.
Searching…
No results for ""
+| Version | |
|---|---|
| /usr/src/linux | |
| installkernel |
| CPV | Type |
|---|---|
|
+ + |
| Version | vmlinuz | initramfs | config | Size | |
|---|---|---|---|---|---|
| + + | +
+
+ running
+ |
+ + | + | + | + |
Waiting for approval. Run this command in a terminal on the host machine:
+ +| Version | /boot | Size | |
|---|---|---|---|
| + + | +
+
+ running
+ |
+ + | + |
Waiting for approval. Run this command in a terminal on the host machine:
+ +| Directory | Type | Symlink | Status | Size | ||
|---|---|---|---|---|---|---|
| + + | + |
+ + | + active + — + | ++ built + mods + /boot + initrd + | ++ |
+
+
+
+
+ |
+
Waiting for approval. Run this command in a terminal on the host machine:
+ +| Package | Type | Status | |
|---|---|---|---|
|
+ + | + + | +
+
+
+
+
+ |
+
+ …
+
+
+
+ Install:
+ …
+
+
+ Waiting for approval. Run this command in a terminal on the host machine:
+ +Vanilla kernel — sources only, not tracked by Portage.
+| Version | Type | Released | |
|---|---|---|---|
|
+ + | + |
+
+
+
+ |
+
Waiting for approval:
+ +
+
+ Limine config must be updated manually — add an entry for the new kernel in
+ .
+
+ If using BIOS boot, run after editing the config: +
+ +Waiting for approval. Run this command in a terminal on the host machine:
+ +
+ Waiting for approval:
+ +No source selected. Go to and click Build → on a kernel source.
+
+
+ Waiting for approval:
+ +For advanced configuration, open a terminal and run make menuconfig in /usr/src/linux
Waiting for approval:
+ +Waiting for approval:
+ +Waiting for approval:
+ +Waiting for approval:
+ +Waiting for approval:
+ +Waiting for approval:
+ ++ This will immediately reboot the system. The browser will lose connection. +
+Waiting for approval:
+ +