diff --git a/.github/workflows/maintenance-desktop-audit.yml b/.github/workflows/maintenance-desktop-audit.yml new file mode 100644 index 000000000..5c2dd4f7a --- /dev/null +++ b/.github/workflows/maintenance-desktop-audit.yml @@ -0,0 +1,187 @@ +name: "Maintenance: Desktop matrix audit" + +# Periodic audit of the desktop YAML matrix against: +# 1. armbian/build's config/distributions/ — flag releases the build +# supports but no DE YAML covers +# 2. packages.debian.org / packages.ubuntu.com — flag DESKTOP_PACKAGES +# entries that don't exist in the upstream archive for the +# requested (release, arch) +# +# When the audit finds work to do, hand the report to Claude (via the +# Anthropic API) and let it propose YAML edits. Then open a PR with +# whatever Claude wrote, using peter-evans/create-pull-request. +# +# The audit_apply.py script short-circuits when there's nothing to do, +# so a "no changes" run never burns API tokens or opens an empty PR. + +on: + schedule: + # Mondays at 06:00 UTC. Weekly is enough — release / package + # availability changes slowly. + - cron: "0 6 * * 1" + workflow_dispatch: + inputs: + tier: + description: "Tier to audit (minimal, mid, full, or empty for all)" + required: false + default: "" + release: + description: "Release codename to audit (empty for all)" + required: false + default: "" + dry_run: + description: "Dry run — do not call Claude or open a PR" + required: false + default: "false" + type: choice + options: + - "false" + - "true" + +permissions: + contents: write + pull-requests: write + +concurrency: + group: desktop-audit + cancel-in-progress: false + +jobs: + audit: + name: "Desktop matrix audit" + runs-on: ubuntu-latest + steps: + - name: "Checkout configng" + uses: actions/checkout@v4 + with: + path: configng + + - name: "Checkout armbian/build" + uses: actions/checkout@v4 + with: + repository: armbian/build + path: build + # Only the config/distributions/ directory matters for the + # audit, but a sparse checkout would complicate things — + # the build repo is small enough to clone shallowly. + fetch-depth: 1 + + - name: "Set up Python" + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: "Install Python dependencies" + run: | + pip install --quiet pyyaml anthropic + + - name: "Run deterministic audit" + id: audit + working-directory: configng + run: | + set -euo pipefail + args=( + --build-repo "${{ github.workspace }}/build" + --configng-repo "${{ github.workspace }}/configng" + --output "${{ github.workspace }}/audit-report.json" + ) + if [[ -n "${{ github.event.inputs.tier }}" ]]; then + args+=(--tier "${{ github.event.inputs.tier }}") + fi + if [[ -n "${{ github.event.inputs.release }}" ]]; then + args+=(--release "${{ github.event.inputs.release }}") + fi + python3 tools/desktops/audit.py "${args[@]}" + + # Surface a summary in the workflow's job summary so reviewers + # can read it without downloading artifacts. + { + echo "## Desktop matrix audit" + echo + python3 - <<'PY' + import json + r = json.load(open("${{ github.workspace }}/audit-report.json")) + print(f"- **Desktops scanned:** {r['stats']['desktops']}") + print(f"- **(release, arch) combinations in scope:** {r['stats']['scope']}") + print(f"- **Package availability checks:** {r['stats']['package_lookups']}") + print(f"- **Holes found:** {r['stats']['holes']}") + print(f"- **Releases not covered:** {len(r['missing_releases'])}") + print() + if r['missing_releases']: + print("### Missing releases") + print() + print("| release | status | name | architectures |") + print("|---|---|---|---|") + for m in r['missing_releases']: + print(f"| `{m['release']}` | {m['support_status']} | {m['name']} | `{','.join(m['architectures'])}` |") + print() + if r['package_holes']: + print("### Package holes") + print() + print("| de | release | arch | tier | missing |") + print("|---|---|---|---|---|") + for h in r['package_holes']: + pkgs = ", ".join(f"`{p}`" for p in h['missing']) + print(f"| `{h['de']}` | `{h['release']}` | `{h['arch']}` | `{h['tier']}` | {pkgs} |") + PY + } >> "$GITHUB_STEP_SUMMARY" + + # Set step output: did we find anything actionable? + python3 - <<'PY' >> "$GITHUB_OUTPUT" + import json + r = json.load(open("${{ github.workspace }}/audit-report.json")) + actionable = bool(r['package_holes'] or r['missing_releases']) + print(f"actionable={'true' if actionable else 'false'}") + PY + + - name: "Upload audit report" + if: always() + uses: actions/upload-artifact@v4 + with: + name: audit-report + path: ${{ github.workspace }}/audit-report.json + retention-days: 30 + + - name: "Run Claude apply step" + id: apply + if: steps.audit.outputs.actionable == 'true' && github.event.inputs.dry_run != 'true' + working-directory: configng + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + run: | + set -euo pipefail + if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then + echo "::warning::ANTHROPIC_API_KEY secret not set; skipping Claude apply step" + echo "skipped=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + python3 tools/desktops/audit_apply.py \ + --report "${{ github.workspace }}/audit-report.json" \ + --configng-repo "${{ github.workspace }}/configng" \ + --summary-output "${{ github.workspace }}/audit-summary.md" + echo "skipped=false" >> "$GITHUB_OUTPUT" + + - name: "Open / update pull request" + if: | + steps.audit.outputs.actionable == 'true' && + github.event.inputs.dry_run != 'true' && + steps.apply.outputs.skipped != 'true' + uses: peter-evans/create-pull-request@v6 + with: + path: configng + branch: bot/desktop-matrix-audit + delete-branch: true + base: main + commit-message: | + desktops: weekly matrix audit fixes (bot) + + Generated by .github/workflows/maintenance-desktop-audit.yml + via Claude API based on the deterministic findings of + tools/desktops/audit.py. + title: "desktops: weekly matrix audit fixes (bot)" + body-path: ${{ github.workspace }}/audit-summary.md + labels: | + bot + desktops + documentation + draft: true diff --git a/tests/BUDG01.conf b/tests/BUDG01.conf index 0a80ac4d6..2f749119f 100644 --- a/tests/BUDG01.conf +++ b/tests/BUDG01.conf @@ -4,6 +4,6 @@ TESTNAME="Budgie desktop" testcase() {( set -e - ./bin/armbian-config --api module_desktops install de=budgie - ./bin/armbian-config --api module_desktops remove de=budgie + ./bin/armbian-config --api module_desktops install de=budgie tier=minimal + ./bin/armbian-config --api module_desktops remove de=budgie )} diff --git a/tests/CINM01.conf b/tests/CINM01.conf index ee2c22145..dd617d8c4 100644 --- a/tests/CINM01.conf +++ b/tests/CINM01.conf @@ -4,6 +4,6 @@ TESTNAME="Cinnamon desktop" testcase() {( set -e - ./bin/armbian-config --api module_desktops install de=cinnamon - ./bin/armbian-config --api module_desktops remove de=cinnamon + ./bin/armbian-config --api module_desktops install de=cinnamon tier=full + ./bin/armbian-config --api module_desktops remove de=cinnamon )} diff --git a/tests/DEEP01.conf b/tests/DEEP01.conf index d6b26a358..e96b94c4b 100644 --- a/tests/DEEP01.conf +++ b/tests/DEEP01.conf @@ -4,6 +4,6 @@ TESTNAME="Deepin desktop" testcase() {( set -e - ./bin/armbian-config --api module_desktops install de=deepin - ./bin/armbian-config --api module_desktops remove de=deepin + ./bin/armbian-config --api module_desktops install de=deepin tier=minimal + ./bin/armbian-config --api module_desktops remove de=deepin )} diff --git a/tests/ENLI01.conf b/tests/ENLI01.conf index c98da7af5..3c605f1d7 100644 --- a/tests/ENLI01.conf +++ b/tests/ENLI01.conf @@ -4,6 +4,6 @@ TESTNAME="enlightenment desktop" testcase() {( set -e - ./bin/armbian-config --api module_desktops install de=enlightenment - ./bin/armbian-config --api module_desktops remove de=enlightenment + ./bin/armbian-config --api module_desktops install de=enlightenment tier=full + ./bin/armbian-config --api module_desktops remove de=enlightenment )} diff --git a/tests/GNME01.conf b/tests/GNME01.conf index 05ccc7455..623220172 100644 --- a/tests/GNME01.conf +++ b/tests/GNME01.conf @@ -4,6 +4,6 @@ TESTNAME="GNOME desktop" testcase() {( set -e - ./bin/armbian-config --api module_desktops install de=gnome - ./bin/armbian-config --api module_desktops remove de=gnome + ./bin/armbian-config --api module_desktops install de=gnome tier=full + ./bin/armbian-config --api module_desktops remove de=gnome )} diff --git a/tests/I3WM01.conf b/tests/I3WM01.conf index 68834d1f1..263d5bf1a 100644 --- a/tests/I3WM01.conf +++ b/tests/I3WM01.conf @@ -4,6 +4,6 @@ TESTNAME="i3-wm desktop" testcase() {( set -e - ./bin/armbian-config --api module_desktops install de=i3-wm - ./bin/armbian-config --api module_desktops remove de=i3-wm + ./bin/armbian-config --api module_desktops install de=i3-wm tier=full + ./bin/armbian-config --api module_desktops remove de=i3-wm )} diff --git a/tests/KDEN01.conf b/tests/KDEN01.conf index 7dfbf033d..23a037296 100644 --- a/tests/KDEN01.conf +++ b/tests/KDEN01.conf @@ -4,6 +4,9 @@ TESTNAME="KDE Neon desktop" testcase() {( set -e - ./bin/armbian-config --api module_desktops install de=kde-neon - ./bin/armbian-config --api module_desktops remove de=kde-neon + # kde-neon is status: unsupported and only declares a minimal + # tier; do not push mid/full common-tier extras (chromium etc.) + # into a desktop that doesn't expect them. + ./bin/armbian-config --api module_desktops install de=kde-neon tier=minimal + ./bin/armbian-config --api module_desktops remove de=kde-neon )} diff --git a/tests/KDEP01.conf b/tests/KDEP01.conf index eda9b66f7..e6dc618ef 100644 --- a/tests/KDEP01.conf +++ b/tests/KDEP01.conf @@ -4,6 +4,6 @@ TESTNAME="KDE Plasma desktop" testcase() {( set -e - ./bin/armbian-config --api module_desktops install de=kde-plasma - ./bin/armbian-config --api module_desktops remove de=kde-plasma + ./bin/armbian-config --api module_desktops install de=kde-plasma tier=full + ./bin/armbian-config --api module_desktops remove de=kde-plasma )} diff --git a/tests/MATE01.conf b/tests/MATE01.conf index e8b83a473..e8f7b1504 100644 --- a/tests/MATE01.conf +++ b/tests/MATE01.conf @@ -4,6 +4,6 @@ TESTNAME="Mate desktop" testcase() {( set -e - ./bin/armbian-config --api module_desktops install de=mate - ./bin/armbian-config --api module_desktops remove de=mate + ./bin/armbian-config --api module_desktops install de=mate tier=full + ./bin/armbian-config --api module_desktops remove de=mate )} diff --git a/tests/XFCE01.conf b/tests/XFCE01.conf index 8fd599e8d..d0ab10969 100644 --- a/tests/XFCE01.conf +++ b/tests/XFCE01.conf @@ -4,6 +4,6 @@ TESTNAME="XFCE desktop" testcase() {( set -e - ./bin/armbian-config --api module_desktops install de=xfce - ./bin/armbian-config --api module_desktops remove de=xfce + ./bin/armbian-config --api module_desktops install de=xfce tier=full + ./bin/armbian-config --api module_desktops remove de=xfce )} diff --git a/tests/XMON01.conf b/tests/XMON01.conf index ea535da6c..b2822d17b 100644 --- a/tests/XMON01.conf +++ b/tests/XMON01.conf @@ -4,6 +4,6 @@ TESTNAME="xmonad desktop" testcase() {( set -e - ./bin/armbian-config --api module_desktops install de=xmonad - ./bin/armbian-config --api module_desktops remove de=xmonad + ./bin/armbian-config --api module_desktops install de=xmonad tier=full + ./bin/armbian-config --api module_desktops remove de=xmonad )} diff --git a/tools/desktops/audit.py b/tools/desktops/audit.py new file mode 100644 index 000000000..79ded34a7 --- /dev/null +++ b/tools/desktops/audit.py @@ -0,0 +1,390 @@ +#!/usr/bin/env python3 +""" +Desktop coverage audit. + +Walks the desktop YAML matrix in tools/modules/desktops/yaml/ against +the list of supported releases from armbian/build's config/distributions/ +and against the actual published binary packages on +packages.debian.org / packages.ubuntu.com. + +Outputs a JSON report describing two things: + + 1. "missing_releases" — releases declared in armbian/build that no DE + YAML covers yet (or covers only partially across the arches build + supports). These are candidates for "add a new release block to + each DE YAML". + + 2. "package_holes" — packages that DESKTOP_PACKAGES (the resolved + install set) names but that don't exist in the upstream archive + for the requested (release, arch). These would cause apt to fail + with 'E: Unable to locate package' if the install actually ran. + +The audit is deterministic: package availability is determined by +fetching packages.debian.org / packages.ubuntu.com and parsing the +HTTP response. No LLM is involved here. + +A second script (audit_apply.py) consumes this JSON and uses the +Claude API to propose YAML edits. + +Usage +----- + audit.py --build-repo /path/to/build/checkout \\ + --configng-repo /path/to/configng/checkout \\ + --output report.json + + audit.py ... --tier minimal # only audit one tier + audit.py ... --release noble # only audit one release + audit.py ... --skip-network # don't actually fetch, dry-run + +Exit codes +---------- + 0 — audit ran successfully (regardless of whether holes were found) + 1 — script error (missing inputs, parser failure, etc.) +""" + +import argparse +import json +import os +import re +import subprocess +import sys +import time +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed +from pathlib import Path + +# Mapping of release codename -> distro family. Used to pick the right +# packages.* host. Releases not in this map are skipped with a warning +# (the audit only knows how to check Debian and Ubuntu archives). +DEBIAN_RELEASES = {"bookworm", "trixie", "forky", "sid"} +UBUNTU_RELEASES = {"jammy", "noble", "oracular", "plucky", "questing", "resolute"} +SKIP_RELEASES = {"buster", "bullseye", "focal"} # EOS, never going to gain a desktop + +# Architecture name normalization. armbian/build uses 'amd64' / 'arm64' +# / 'armhf' / 'riscv64'. packages.debian.org uses the same names. +SUPPORTED_ARCHES = {"amd64", "arm64", "armhf", "riscv64"} + +# How long to wait between HTTP requests to one host. Be polite to +# packages.debian.org / packages.ubuntu.com. +HTTP_DELAY_SECONDS = 0.1 +HTTP_TIMEOUT_SECONDS = 15 +HTTP_RETRIES = 2 +USER_AGENT = "armbian-configng-desktop-audit/1.0 (+https://github.com/armbian/configng)" + + +# ---------------------------------------------------------------------- +# armbian/build distributions parser +# ---------------------------------------------------------------------- + +def parse_build_distributions(build_repo: Path) -> dict: + """ + Read armbian/build's config/distributions/ directory and return a + map of {release_codename: {name, support, architectures}}. + + Each release is a directory containing the files: name, support, + architectures (CSV), order, upgrade. + """ + dist_dir = build_repo / "config" / "distributions" + if not dist_dir.is_dir(): + die(f"build repo distributions dir not found at {dist_dir}") + + out = {} + for child in sorted(dist_dir.iterdir()): + if not child.is_dir(): + continue + codename = child.name + try: + name = (child / "name").read_text().strip() + support = (child / "support").read_text().strip() + arches_csv = (child / "architectures").read_text().strip() + except FileNotFoundError: + # malformed entry, skip + continue + out[codename] = { + "name": name, + "support": support, + "architectures": [a.strip() for a in arches_csv.split(",") if a.strip()], + } + return out + + +# ---------------------------------------------------------------------- +# Desktop YAML matrix +# ---------------------------------------------------------------------- + +def list_desktops(yaml_dir: Path) -> list[str]: + """Return the list of DE names declared in tools/modules/desktops/yaml/.""" + return sorted( + f.stem for f in yaml_dir.glob("*.yaml") + if f.name != "common.yaml" + ) + + +def parse_desktop_yaml(yaml_dir: Path, parser_path: Path, + de: str, release: str, arch: str, tier: str) -> dict: + """ + Run parse_desktop_yaml.py and capture the DESKTOP_* output as a dict. + Returns {} on parse failure (with stderr printed). + """ + cmd = [ + sys.executable, str(parser_path), str(yaml_dir), + de, release, arch, "--tier", tier, + ] + try: + proc = subprocess.run( + cmd, capture_output=True, text=True, check=False, timeout=30, + ) + except subprocess.TimeoutExpired: + warn(f"parser timed out: {de} {release} {arch} {tier}") + return {} + + if proc.returncode != 0: + # parser refuses to run for various legitimate reasons (release + # block missing, arch not supported by the YAML, etc.); we + # don't treat this as a script failure. + return {} + + out = {} + for line in proc.stdout.splitlines(): + m = re.match(r'^([A-Z_]+)="(.*)"$', line) + if m: + out[m.group(1)] = m.group(2) + return out + + +# ---------------------------------------------------------------------- +# Package availability +# ---------------------------------------------------------------------- + +def package_url(release: str, arch: str, package: str) -> str | None: + """Return the canonical packages.debian.org / packages.ubuntu.com URL.""" + if release in DEBIAN_RELEASES: + return f"https://packages.debian.org/{release}/{arch}/{package}" + if release in UBUNTU_RELEASES: + return f"https://packages.ubuntu.com/{release}/{arch}/{package}" + return None + + +def package_exists(release: str, arch: str, package: str, + cache: dict, skip_network: bool) -> bool | None: + """ + True if the package is published in (release, arch). False if not. + None if we couldn't tell (unknown release, network error, etc.). + + Results are memoised in `cache` to avoid hammering the archive. + """ + key = (release, arch, package) + if key in cache: + return cache[key] + + url = package_url(release, arch, package) + if url is None: + cache[key] = None + return None + + if skip_network: + cache[key] = None + return None + + last_err = None + for attempt in range(HTTP_RETRIES + 1): + try: + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT_SECONDS) as resp: + # 200 = page exists. packages.debian.org returns 200 + # with a "no such package" body for missing packages, + # so we have to peek at the body. + body = resp.read(8192).decode("utf-8", errors="replace") + exists = "No such package" not in body and "is not available" not in body + cache[key] = exists + time.sleep(HTTP_DELAY_SECONDS) + return exists + except urllib.error.HTTPError as e: + if e.code == 404: + cache[key] = False + return False + last_err = e + except (urllib.error.URLError, TimeoutError) as e: + last_err = e + # backoff before retry + time.sleep(0.5 * (attempt + 1)) + + warn(f"network error checking {package} on {release}/{arch}: {last_err}") + cache[key] = None + return None + + +# ---------------------------------------------------------------------- +# Audit logic +# ---------------------------------------------------------------------- + +def audit(build_repo: Path, configng_repo: Path, + tier_filter: str | None, release_filter: str | None, + skip_network: bool) -> dict: + distributions = parse_build_distributions(build_repo) + yaml_dir = configng_repo / "tools" / "modules" / "desktops" / "yaml" + parser_path = (configng_repo / "tools" / "modules" / "desktops" / + "scripts" / "parse_desktop_yaml.py") + if not parser_path.exists(): + die(f"parser not found at {parser_path}") + + desktops = list_desktops(yaml_dir) + info(f"found {len(desktops)} DE YAMLs: {', '.join(desktops)}") + + # Walk the build distributions and figure out which (release, arch) + # pairs are in scope for the audit. Skip end-of-support releases. + in_scope = [] + for codename, meta in distributions.items(): + if codename in SKIP_RELEASES: + continue + if release_filter and codename != release_filter: + continue + if codename not in DEBIAN_RELEASES and codename not in UBUNTU_RELEASES: + warn(f"skipping {codename}: unknown release family") + continue + for arch in meta["architectures"]: + if arch not in SUPPORTED_ARCHES: + continue + in_scope.append((codename, arch, meta["support"])) + + # Find releases that build supports but no DE YAML covers. We use + # the set of release names that appear in any DE YAML's `releases:` + # block as the universe. + yaml_releases = set() + for de in desktops: + for tier in ("minimal",): # release block is orthogonal to tier + data = parse_desktop_yaml(yaml_dir, parser_path, de, + release="trixie", arch="amd64", tier=tier) + # we only care about side-effect of importing the YAML, but + # we can't easily extract `releases` keys from the parser's + # output. Read the YAML directly instead. + try: + import yaml as pyyaml + with (yaml_dir / f"{de}.yaml").open() as f: + doc = pyyaml.safe_load(f) or {} + for rel in (doc.get("releases") or {}).keys(): + yaml_releases.add(rel) + except Exception as e: + warn(f"could not load {de}.yaml: {e}") + + missing_releases = [] + for codename, _arch, support in in_scope: + if codename not in yaml_releases: + # Skip end-of-support releases — there's no point asking + # the LLM to add YAML coverage for a release the build is + # winding down. Only flag releases the build is actively + # maintaining. + if support in ("eos", "wip"): + continue + entry = { + "release": codename, + "support_status": support, + "name": distributions[codename]["name"], + "architectures": distributions[codename]["architectures"], + } + if entry not in missing_releases: + missing_releases.append(entry) + + # Audit packages: for every supported DE × in-scope (release, arch) + # × tier, parse the YAML and check each resolved package against + # the upstream archive. Cache results aggressively — most packages + # are shared across DEs. + cache = {} + package_holes = [] # list of {de, release, arch, tier, missing: [pkgs]} + + tiers_to_check = [tier_filter] if tier_filter else ["minimal", "mid", "full"] + + for de in desktops: + for codename, arch, _support in in_scope: + if codename not in yaml_releases: + # no point checking a release that the YAML doesn't list + # — there are no resolved packages for it. + continue + for tier in tiers_to_check: + parsed = parse_desktop_yaml(yaml_dir, parser_path, de, + codename, arch, tier) + if not parsed.get("DESKTOP_SUPPORTED") == "yes": + # the YAML doesn't support this (DE, release, arch); + # skip it. + continue + pkgs = parsed.get("DESKTOP_PACKAGES", "").split() + missing = [] + for pkg in pkgs: + exists = package_exists(codename, arch, pkg, cache, skip_network) + if exists is False: + missing.append(pkg) + if missing: + package_holes.append({ + "de": de, + "release": codename, + "arch": arch, + "tier": tier, + "missing": missing, + }) + info(f"hole: {de} {codename}/{arch} {tier} → {', '.join(missing)}") + + return { + "scanned_releases": sorted(yaml_releases), + "build_distributions": distributions, + "missing_releases": missing_releases, + "package_holes": package_holes, + "stats": { + "desktops": len(desktops), + "scope": len(in_scope), + "holes": len(package_holes), + "package_lookups": len(cache), + }, + } + + +# ---------------------------------------------------------------------- +# Helpers +# ---------------------------------------------------------------------- + +def die(msg: str): + print(f"audit.py: error: {msg}", file=sys.stderr) + sys.exit(1) + + +def warn(msg: str): + print(f"audit.py: warning: {msg}", file=sys.stderr) + + +def info(msg: str): + print(f"audit.py: {msg}", file=sys.stderr) + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--build-repo", type=Path, required=True, + help="path to an armbian/build checkout") + ap.add_argument("--configng-repo", type=Path, required=True, + help="path to an armbian/configng checkout") + ap.add_argument("--output", type=Path, default=Path("audit-report.json"), + help="output JSON report path (default: audit-report.json)") + ap.add_argument("--tier", choices=["minimal", "mid", "full"], default=None, + help="audit only this tier (default: all three)") + ap.add_argument("--release", default=None, + help="audit only this release codename") + ap.add_argument("--skip-network", action="store_true", + help="don't actually fetch packages.* — useful for dry runs") + args = ap.parse_args() + + if not args.build_repo.is_dir(): + die(f"--build-repo not a directory: {args.build_repo}") + if not args.configng_repo.is_dir(): + die(f"--configng-repo not a directory: {args.configng_repo}") + + report = audit(args.build_repo, args.configng_repo, + args.tier, args.release, args.skip_network) + + args.output.write_text(json.dumps(report, indent=2)) + info(f"wrote {args.output}") + info(f"summary: {report['stats']['holes']} package holes, " + f"{len(report['missing_releases'])} releases not covered by any DE YAML") + + +if __name__ == "__main__": + main() diff --git a/tools/desktops/audit_apply.py b/tools/desktops/audit_apply.py new file mode 100644 index 000000000..e79d790ed --- /dev/null +++ b/tools/desktops/audit_apply.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 +""" +Hand the audit report to Claude and let it propose YAML edits. + +Reads the JSON report produced by audit.py and invokes the Anthropic +API. Claude is told: + + - here are the package holes (DESKTOP_PACKAGES entries that don't + exist in the upstream archive for some (release, arch)) + - here are the releases the build supports but no DE YAML covers + - here are the existing YAML files + - propose minimal edits that fix the holes and add the missing + releases, with comments explaining why + +Claude has tool access to read/write files in the configng repo. After +it finishes, the script validates the YAMLs still parse and that the +parser produces no new holes for the affected combinations. + +The script does NOT open a PR — that's left to peter-evans/create-pull-request +in the GitHub Actions workflow, which picks up whatever Claude wrote +and makes a PR with auto-generated branch and label. + +Usage +----- + audit_apply.py --report audit-report.json \\ + --configng-repo /path/to/configng/checkout \\ + [--dry-run] # don't actually call the API + [--max-tokens 50000] # cap the conversation + [--model claude-...] # override default model + +Environment +----------- + ANTHROPIC_API_KEY — required (unless --dry-run) + +Exit codes +---------- + 0 — Claude ran (or dry-run completed) + 1 — script error + 2 — Claude returned but the post-edit validation failed +""" + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + +DEFAULT_MODEL = "claude-sonnet-4-5-20250929" +DEFAULT_MAX_TOKENS = 50_000 + +SYSTEM_PROMPT = """\ +You are an Armbian configng maintainer. You're auditing the desktop +YAML matrix in tools/modules/desktops/yaml/ for two kinds of problems: + +1. Package holes — packages that the resolved DESKTOP_PACKAGES set + names, but that don't actually exist in the upstream Debian/Ubuntu + archive for the requested (release, arch). When the install runs, + apt fails with "E: Unable to locate package ". + +2. Missing releases — releases that armbian/build supports but no DE + YAML has a release block for. These desktops can't be installed on + those releases at all. + +Your job is to propose minimal YAML edits that fix the audit findings +without breaking the existing matrix. Specifically: + +- For package holes, prefer adding entries to common.yaml's + tier_overrides block (one place, applies to every DE) rather than + duplicating the same removal in every per-DE YAML. Use the per- + release-per-arch nesting (`tier_overrides..releases.. + architectures..packages_remove`) for transient holes; use the + per-arch nesting (`tier_overrides..architectures.. + packages_remove`) for permanent arch-wide holes. + +- For missing releases, add a release block to each currently- + supported DE YAML (the ones with `status: supported`). Copy the + shape from an existing release block (e.g. trixie or noble) and + adjust per-release deltas only when needed. + +- Always add a comment explaining WHY a hole exists. Future readers + should be able to tell whether the entry is a transient archive + hole (that may go away in a later release) or a permanent + upstream-port limitation. + +- Never edit YAML files outside tools/modules/desktops/yaml/. + +- Preserve the existing tab/space indentation style. The YAML files + use 2-space indentation; do not introduce tabs. + +- When you finish, summarize what you changed and why in plain prose + (not as a tool call). The script will read your final message and + use it as the PR body. +""" + +USER_PROMPT_TEMPLATE = """\ +# Desktop matrix audit + +The deterministic audit script (tools/desktops/audit.py) just ran +against the current state of tools/modules/desktops/yaml/ and the +list of supported releases in armbian/build's config/distributions/. +Here's what it found. + +## Package holes ({hole_count} total) + +These are packages that the resolved DESKTOP_PACKAGES set lists but +that don't exist in the upstream archive for that (release, arch). +The install would fail with `E: Unable to locate package` if it ran. + +```json +{holes_json} +``` + +## Missing releases ({missing_release_count} total) + +These releases are listed in armbian/build's config/distributions/ +with a non-EOS status, but no DE YAML has a release block for them. +Desktops can't be installed on these releases until we add coverage. + +```json +{missing_releases_json} +``` + +## Stats + +- {desktops} DE YAMLs scanned +- {scope} (release, arch) combinations in scope +- {package_lookups} package availability checks performed + +## What I want from you + +Propose minimal edits that fix the holes and add coverage for the +missing releases. Read the YAML files first to understand the +existing pattern, then make the edits via Edit/Write. When you're +done, write a short summary (under 300 words) describing what you +changed and why. + +If there are no holes and no missing releases, say so explicitly +and don't make any edits. +""" + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--report", type=Path, required=True, + help="path to the JSON report from audit.py") + ap.add_argument("--configng-repo", type=Path, required=True, + help="path to the configng checkout (LLM will edit files here)") + ap.add_argument("--dry-run", action="store_true", + help="don't actually call the API; print the prompt and exit") + ap.add_argument("--max-tokens", type=int, default=DEFAULT_MAX_TOKENS, + help=f"max tokens for the conversation (default: {DEFAULT_MAX_TOKENS})") + ap.add_argument("--model", default=DEFAULT_MODEL, + help=f"Claude model id (default: {DEFAULT_MODEL})") + ap.add_argument("--summary-output", type=Path, default=Path("audit-summary.md"), + help="path to write Claude's final summary (default: audit-summary.md)") + args = ap.parse_args() + + if not args.report.is_file(): + die(f"--report not found: {args.report}") + if not args.configng_repo.is_dir(): + die(f"--configng-repo not a directory: {args.configng_repo}") + + report = json.loads(args.report.read_text()) + holes = report.get("package_holes", []) + missing = report.get("missing_releases", []) + + # Short-circuit: if there's nothing to do, don't burn API tokens. + if not holes and not missing: + info("audit found no holes and no missing releases — nothing to do") + args.summary_output.write_text( + "# Desktop audit\n\nNo holes or missing releases found. " + "No changes proposed.\n" + ) + return 0 + + user_prompt = USER_PROMPT_TEMPLATE.format( + hole_count=len(holes), + missing_release_count=len(missing), + holes_json=json.dumps(holes, indent=2), + missing_releases_json=json.dumps(missing, indent=2), + desktops=report["stats"]["desktops"], + scope=report["stats"]["scope"], + package_lookups=report["stats"]["package_lookups"], + ) + + if args.dry_run: + info("--dry-run: not calling the API") + print("=" * 60) + print("SYSTEM PROMPT:") + print("=" * 60) + print(SYSTEM_PROMPT) + print("=" * 60) + print("USER PROMPT:") + print("=" * 60) + print(user_prompt) + return 0 + + api_key = os.environ.get("ANTHROPIC_API_KEY") + if not api_key: + die("ANTHROPIC_API_KEY environment variable not set") + + # Defer the SDK import so --dry-run works without the SDK installed. + try: + from anthropic import Anthropic + except ImportError: + die("anthropic SDK not installed: pip install anthropic") + + client = Anthropic(api_key=api_key) + info(f"calling Claude ({args.model}) ...") + + summary = run_claude( + client=client, + model=args.model, + max_tokens=args.max_tokens, + system_prompt=SYSTEM_PROMPT, + user_prompt=user_prompt, + configng_repo=args.configng_repo, + ) + + args.summary_output.write_text(summary) + info(f"wrote {args.summary_output}") + + # Post-edit validation: every YAML file should still parse and the + # parser should produce a non-empty package list for every + # supported (DE, release, arch, tier) combination that previously + # worked. We don't try to verify "Claude fixed every hole" — that's + # what the next audit run will tell us. + if not validate_post_edit(args.configng_repo): + die("post-edit validation failed", exit_code=2) + + info("post-edit validation passed") + return 0 + + +def run_claude(*, client, model, max_tokens, system_prompt, user_prompt, + configng_repo: Path) -> str: + """ + Run a single Claude conversation with file-editing tool access. + Returns Claude's final text message (used as the PR body). + """ + # Tool definitions: Read, Write, Edit. Bash is intentionally NOT + # exposed — Claude shouldn't be running arbitrary commands, only + # editing YAML files in tools/modules/desktops/yaml/. + tools = [ + { + "name": "read_file", + "description": "Read a file from the configng repo. Path must be relative to the repo root.", + "input_schema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "relative path"}, + }, + "required": ["path"], + }, + }, + { + "name": "write_file", + "description": "Write a file in the configng repo, overwriting any existing content. Path must be inside tools/modules/desktops/yaml/.", + "input_schema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "relative path inside tools/modules/desktops/yaml/"}, + "content": {"type": "string", "description": "full file content"}, + }, + "required": ["path", "content"], + }, + }, + { + "name": "list_yaml_dir", + "description": "List the YAML files in tools/modules/desktops/yaml/.", + "input_schema": {"type": "object", "properties": {}}, + }, + ] + + yaml_dir = configng_repo / "tools" / "modules" / "desktops" / "yaml" + messages = [{"role": "user", "content": user_prompt}] + final_text = "" + + while True: + resp = client.messages.create( + model=model, + max_tokens=8192, # per-response cap + system=system_prompt, + tools=tools, + messages=messages, + ) + + # Collect assistant text + any tool calls. + assistant_blocks = [] + tool_results = [] + for block in resp.content: + assistant_blocks.append(block) + if block.type == "text": + final_text = block.text # last text block wins + elif block.type == "tool_use": + result = handle_tool(block.name, block.input, + configng_repo=configng_repo, + yaml_dir=yaml_dir) + tool_results.append({ + "type": "tool_result", + "tool_use_id": block.id, + "content": result, + }) + + messages.append({"role": "assistant", "content": assistant_blocks}) + + if resp.stop_reason == "end_turn" or not tool_results: + break + + messages.append({"role": "user", "content": tool_results}) + + # Cheap budget guard: count rough total tokens used so far. + # The Anthropic SDK exposes usage on each response. + usage = resp.usage + if usage and (usage.input_tokens + usage.output_tokens) > max_tokens: + warn(f"token budget exceeded ({usage.input_tokens + usage.output_tokens} > {max_tokens})") + break + + return final_text or "(Claude returned no text summary)" + + +def handle_tool(name: str, args: dict, *, configng_repo: Path, yaml_dir: Path) -> str: + """Execute one tool call and return the result string.""" + try: + if name == "list_yaml_dir": + files = sorted(f.name for f in yaml_dir.glob("*.yaml")) + return "\n".join(files) + + if name == "read_file": + path = configng_repo / args["path"] + try: + resolved = path.resolve() + resolved.relative_to(configng_repo.resolve()) + except (ValueError, OSError): + return "ERROR: path outside configng repo" + if not resolved.is_file(): + return f"ERROR: not a file: {args['path']}" + return resolved.read_text() + + if name == "write_file": + rel = Path(args["path"]) + # Sandbox: only files inside the yaml dir. + target = (configng_repo / rel).resolve() + try: + target.relative_to(yaml_dir.resolve()) + except ValueError: + return f"ERROR: write outside tools/modules/desktops/yaml/ rejected: {rel}" + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(args["content"]) + return f"OK: wrote {rel} ({len(args['content'])} bytes)" + + return f"ERROR: unknown tool {name}" + except Exception as e: + return f"ERROR: {type(e).__name__}: {e}" + + +def validate_post_edit(configng_repo: Path) -> bool: + """ + Sanity-check after Claude's edits: + 1. Every YAML in tools/modules/desktops/yaml/ still parses. + 2. The parser still runs (no crashes) for a few representative + combinations. + """ + yaml_dir = configng_repo / "tools" / "modules" / "desktops" / "yaml" + parser = (configng_repo / "tools" / "modules" / "desktops" / + "scripts" / "parse_desktop_yaml.py") + + try: + import yaml as pyyaml + except ImportError: + warn("pyyaml not available — skipping YAML parse validation") + return True + + for f in yaml_dir.glob("*.yaml"): + try: + with f.open() as fh: + pyyaml.safe_load(fh) + except Exception as e: + warn(f"YAML parse failure in {f.name}: {e}") + return False + + # Spot-check a few parser invocations + spot_checks = [ + ("xfce", "trixie", "amd64", "minimal"), + ("xfce", "noble", "arm64", "full"), + ("gnome", "trixie", "amd64", "mid"), + ] + for de, release, arch, tier in spot_checks: + try: + proc = subprocess.run( + [sys.executable, str(parser), str(yaml_dir), + de, release, arch, "--tier", tier], + capture_output=True, text=True, timeout=15, + ) + except subprocess.TimeoutExpired: + warn(f"parser timed out: {de} {release} {arch} {tier}") + return False + if proc.returncode != 0: + warn(f"parser failed for {de} {release} {arch} {tier}: {proc.stderr}") + return False + return True + + +def die(msg: str, exit_code: int = 1): + print(f"audit_apply.py: error: {msg}", file=sys.stderr) + sys.exit(exit_code) + + +def warn(msg: str): + print(f"audit_apply.py: warning: {msg}", file=sys.stderr) + + +def info(msg: str): + print(f"audit_apply.py: {msg}", file=sys.stderr) + + +if __name__ == "__main__": + sys.exit(main() or 0) diff --git a/tools/json/config.system.json b/tools/json/config.system.json index 5c619ea60..55191c0f6 100644 --- a/tools/json/config.system.json +++ b/tools/json/config.system.json @@ -112,70 +112,190 @@ "sub": [ { "id": "CINM01", - "description": "Install Cinnamon", + "description": "Install Cinnamon (minimal)", "short": "Cinnamon", "command": [ - "module_desktops install de=cinnamon" + "module_desktops install de=cinnamon tier=minimal" ], "status": "Stable", "author": "@igorpecovnik", "condition": "! module_desktops installed && module_desktop_supported cinnamon", - "help": "Install the Cinnamon desktop environment" + "help": "Install Cinnamon: desktop, display manager, base utilities. ~500 MB." + }, + { + "id": "CINM05", + "description": "Install Cinnamon (mid)", + "short": "Cinnamon mid", + "command": [ + "module_desktops install de=cinnamon tier=mid" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "! module_desktops installed && module_desktop_supported cinnamon", + "help": "Install Cinnamon: minimal + browser + everyday tools. ~1 GB." + }, + { + "id": "CINM06", + "description": "Install Cinnamon (full)", + "short": "Cinnamon full", + "command": [ + "module_desktops install de=cinnamon tier=full" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "! module_desktops installed && module_desktop_supported cinnamon", + "help": "Install Cinnamon: mid + LibreOffice, GIMP, Inkscape, Thunderbird, Audacity. ~2.5 GB." }, { "id": "GNME01", - "description": "Install GNOME", + "description": "Install GNOME (minimal)", "short": "GNOME", "command": [ - "module_desktops install de=gnome" + "module_desktops install de=gnome tier=minimal" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "! module_desktops installed && module_desktop_supported gnome", + "help": "Install GNOME: desktop, display manager, base utilities. ~500 MB." + }, + { + "id": "GNME05", + "description": "Install GNOME (mid)", + "short": "GNOME mid", + "command": [ + "module_desktops install de=gnome tier=mid" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "! module_desktops installed && module_desktop_supported gnome", + "help": "Install GNOME: minimal + browser + everyday tools. ~1 GB." + }, + { + "id": "GNME06", + "description": "Install GNOME (full)", + "short": "GNOME full", + "command": [ + "module_desktops install de=gnome tier=full" ], "status": "Stable", "author": "@igorpecovnik", "condition": "! module_desktops installed && module_desktop_supported gnome", - "help": "Install the GNOME desktop environment" + "help": "Install GNOME: mid + LibreOffice, GIMP, Inkscape, Thunderbird, Audacity. ~2.5 GB." }, { "id": "MATE01", - "description": "Install MATE", + "description": "Install MATE (minimal)", "short": "MATE", "command": [ - "module_desktops install de=mate" + "module_desktops install de=mate tier=minimal" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "! module_desktops installed && module_desktop_supported mate", + "help": "Install MATE: desktop, display manager, base utilities. ~500 MB." + }, + { + "id": "MATE05", + "description": "Install MATE (mid)", + "short": "MATE mid", + "command": [ + "module_desktops install de=mate tier=mid" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "! module_desktops installed && module_desktop_supported mate", + "help": "Install MATE: minimal + browser + everyday tools. ~1 GB." + }, + { + "id": "MATE06", + "description": "Install MATE (full)", + "short": "MATE full", + "command": [ + "module_desktops install de=mate tier=full" ], "status": "Stable", "author": "@igorpecovnik", "condition": "! module_desktops installed && module_desktop_supported mate", - "help": "Install the MATE desktop environment" + "help": "Install MATE: mid + LibreOffice, GIMP, Inkscape, Thunderbird, Audacity. ~2.5 GB." }, { "id": "I3WM01", - "description": "Install i3", + "description": "Install i3 (minimal)", "short": "i3", "command": [ - "module_desktops install de=i3-wm" + "module_desktops install de=i3-wm tier=minimal" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "! module_desktops installed && module_desktop_supported i3-wm", + "help": "Install i3: tiling window manager, display manager, base utilities. ~400 MB." + }, + { + "id": "I3WM05", + "description": "Install i3 (mid)", + "short": "i3 mid", + "command": [ + "module_desktops install de=i3-wm tier=mid" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "! module_desktops installed && module_desktop_supported i3-wm", + "help": "Install i3: minimal + browser + everyday tools. ~1 GB." + }, + { + "id": "I3WM06", + "description": "Install i3 (full)", + "short": "i3 full", + "command": [ + "module_desktops install de=i3-wm tier=full" ], "status": "Stable", "author": "@igorpecovnik", "condition": "! module_desktops installed && module_desktop_supported i3-wm", - "help": "Install the i3 desktop environment" + "help": "Install i3: mid + LibreOffice, GIMP, Inkscape, Thunderbird, Audacity. ~2.5 GB." }, { "id": "KDEP01", - "description": "Install KDE Plasma", + "description": "Install KDE Plasma (minimal)", "short": "KDE Plasma", "command": [ - "module_desktops install de=kde-plasma" + "module_desktops install de=kde-plasma tier=minimal" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "! module_desktops installed && module_desktop_supported kde-plasma", + "help": "Install KDE Plasma: desktop, display manager, KDE base apps. ~700 MB." + }, + { + "id": "KDEP05", + "description": "Install KDE Plasma (mid)", + "short": "KDE Plasma mid", + "command": [ + "module_desktops install de=kde-plasma tier=mid" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "! module_desktops installed && module_desktop_supported kde-plasma", + "help": "Install KDE Plasma: minimal + browser, kate editor, calculator, vlc. ~1.2 GB." + }, + { + "id": "KDEP06", + "description": "Install KDE Plasma (full)", + "short": "KDE Plasma full", + "command": [ + "module_desktops install de=kde-plasma tier=full" ], "status": "Stable", "author": "@igorpecovnik", "condition": "! module_desktops installed && module_desktop_supported kde-plasma", - "help": "Install the KDE Plasma desktop environment" + "help": "Install KDE Plasma: mid + LibreOffice (KDE), GIMP, Inkscape, Thunderbird, Audacity. ~2.5 GB." }, { "id": "KDEN01", "description": "Install KDE Neon", "short": "KDE Neon", "command": [ - "module_desktops install de=kde-neon" + "module_desktops install de=kde-neon tier=minimal" ], "status": "Stable", "author": "@igorpecovnik", @@ -184,15 +304,39 @@ }, { "id": "XFCE01", - "description": "Install XFCE", + "description": "Install XFCE (minimal)", "short": "XFCE", "command": [ - "module_desktops install de=xfce" + "module_desktops install de=xfce tier=minimal" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "! module_desktops installed && module_desktop_supported xfce", + "help": "Install XFCE: desktop, display manager, base utilities. ~500 MB." + }, + { + "id": "XFCE05", + "description": "Install XFCE (mid)", + "short": "XFCE mid", + "command": [ + "module_desktops install de=xfce tier=mid" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "! module_desktops installed && module_desktop_supported xfce", + "help": "Install XFCE: minimal + browser + everyday tools (text editor, calculator, image/PDF viewer, media player, archive, torrent). ~1 GB." + }, + { + "id": "XFCE06", + "description": "Install XFCE (full)", + "short": "XFCE full", + "command": [ + "module_desktops install de=xfce tier=full" ], "status": "Stable", "author": "@igorpecovnik", "condition": "! module_desktops installed && module_desktop_supported xfce", - "help": "Install the XFCE desktop environment" + "help": "Install XFCE: mid + LibreOffice, GIMP, Inkscape, Thunderbird, Audacity. ~2.5 GB." }, { "id": "CINM02", @@ -425,6 +569,204 @@ "condition": "module_desktops status de=xfce && module_desktops login de=xfce", "help": "Disable automatic desktop login for XFCE" }, + { + "id": "CINM07", + "description": "Change Cinnamon to minimal", + "command": [ + "module_desktops set-tier de=cinnamon tier=minimal" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "module_desktops status de=cinnamon && ! module_desktops at-tier de=cinnamon tier=minimal", + "help": "Downgrade Cinnamon to the minimal tier (removes mid/full extras)" + }, + { + "id": "CINM08", + "description": "Change Cinnamon to mid", + "command": [ + "module_desktops set-tier de=cinnamon tier=mid" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "module_desktops status de=cinnamon && ! module_desktops at-tier de=cinnamon tier=mid", + "help": "Move Cinnamon to the mid tier (adds browser, editor, media tools; removes office apps if downgrading)" + }, + { + "id": "CINM09", + "description": "Change Cinnamon to full", + "command": [ + "module_desktops set-tier de=cinnamon tier=full" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "module_desktops status de=cinnamon && ! module_desktops at-tier de=cinnamon tier=full", + "help": "Upgrade Cinnamon to the full tier (adds office, GIMP, Inkscape, etc.)" + }, + { + "id": "GNME07", + "description": "Change GNOME to minimal", + "command": [ + "module_desktops set-tier de=gnome tier=minimal" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "module_desktops status de=gnome && ! module_desktops at-tier de=gnome tier=minimal", + "help": "Downgrade GNOME to the minimal tier" + }, + { + "id": "GNME08", + "description": "Change GNOME to mid", + "command": [ + "module_desktops set-tier de=gnome tier=mid" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "module_desktops status de=gnome && ! module_desktops at-tier de=gnome tier=mid", + "help": "Move GNOME to the mid tier" + }, + { + "id": "GNME09", + "description": "Change GNOME to full", + "command": [ + "module_desktops set-tier de=gnome tier=full" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "module_desktops status de=gnome && ! module_desktops at-tier de=gnome tier=full", + "help": "Upgrade GNOME to the full tier" + }, + { + "id": "MATE07", + "description": "Change MATE to minimal", + "command": [ + "module_desktops set-tier de=mate tier=minimal" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "module_desktops status de=mate && ! module_desktops at-tier de=mate tier=minimal", + "help": "Downgrade MATE to the minimal tier" + }, + { + "id": "MATE08", + "description": "Change MATE to mid", + "command": [ + "module_desktops set-tier de=mate tier=mid" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "module_desktops status de=mate && ! module_desktops at-tier de=mate tier=mid", + "help": "Move MATE to the mid tier" + }, + { + "id": "MATE09", + "description": "Change MATE to full", + "command": [ + "module_desktops set-tier de=mate tier=full" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "module_desktops status de=mate && ! module_desktops at-tier de=mate tier=full", + "help": "Upgrade MATE to the full tier" + }, + { + "id": "I3WM07", + "description": "Change i3 to minimal", + "command": [ + "module_desktops set-tier de=i3-wm tier=minimal" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "module_desktops status de=i3-wm && ! module_desktops at-tier de=i3-wm tier=minimal", + "help": "Downgrade i3 to the minimal tier" + }, + { + "id": "I3WM08", + "description": "Change i3 to mid", + "command": [ + "module_desktops set-tier de=i3-wm tier=mid" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "module_desktops status de=i3-wm && ! module_desktops at-tier de=i3-wm tier=mid", + "help": "Move i3 to the mid tier" + }, + { + "id": "I3WM09", + "description": "Change i3 to full", + "command": [ + "module_desktops set-tier de=i3-wm tier=full" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "module_desktops status de=i3-wm && ! module_desktops at-tier de=i3-wm tier=full", + "help": "Upgrade i3 to the full tier" + }, + { + "id": "KDEP07", + "description": "Change KDE Plasma to minimal", + "command": [ + "module_desktops set-tier de=kde-plasma tier=minimal" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "module_desktops status de=kde-plasma && ! module_desktops at-tier de=kde-plasma tier=minimal", + "help": "Downgrade KDE Plasma to the minimal tier" + }, + { + "id": "KDEP08", + "description": "Change KDE Plasma to mid", + "command": [ + "module_desktops set-tier de=kde-plasma tier=mid" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "module_desktops status de=kde-plasma && ! module_desktops at-tier de=kde-plasma tier=mid", + "help": "Move KDE Plasma to the mid tier" + }, + { + "id": "KDEP09", + "description": "Change KDE Plasma to full", + "command": [ + "module_desktops set-tier de=kde-plasma tier=full" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "module_desktops status de=kde-plasma && ! module_desktops at-tier de=kde-plasma tier=full", + "help": "Upgrade KDE Plasma to the full tier" + }, + { + "id": "XFCE07", + "description": "Change XFCE to minimal", + "command": [ + "module_desktops set-tier de=xfce tier=minimal" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "module_desktops status de=xfce && ! module_desktops at-tier de=xfce tier=minimal", + "help": "Downgrade XFCE to the minimal tier" + }, + { + "id": "XFCE08", + "description": "Change XFCE to mid", + "command": [ + "module_desktops set-tier de=xfce tier=mid" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "module_desktops status de=xfce && ! module_desktops at-tier de=xfce tier=mid", + "help": "Move XFCE to the mid tier" + }, + { + "id": "XFCE09", + "description": "Change XFCE to full", + "command": [ + "module_desktops set-tier de=xfce tier=full" + ], + "status": "Stable", + "author": "@igorpecovnik", + "condition": "module_desktops status de=xfce && ! module_desktops at-tier de=xfce tier=full", + "help": "Upgrade XFCE to the full tier" + }, { "id": "Xapian", "description": "Improve application search speed", diff --git a/tools/modules/desktops/branding/armbian.xml b/tools/modules/desktops/branding/armbian.xml index e5f278fe3..5116a530f 100644 --- a/tools/modules/desktops/branding/armbian.xml +++ b/tools/modules/desktops/branding/armbian.xml @@ -44,8 +44,8 @@ #000000 - Armbian green-retro - /usr/share/backgrounds/armbian/armbian-4k-green-retro.jpg + Armbian retro-green + /usr/share/backgrounds/armbian/armbian-4k-retro-green.jpg zoom #ffffff #000000 @@ -79,13 +79,13 @@ #000000 - Armbian purple-penguine - /usr/share/backgrounds/armbian/armbian-4k-purple-penguine.jpg + Armbian purple-penguin + /usr/share/backgrounds/armbian/armbian-4k-purple-penguin.jpg zoom #ffffff #000000 - + Armbian purplepunk-resultado /usr/share/backgrounds/armbian/armbian-4k-purplepunk.jpg zoom @@ -122,7 +122,7 @@ Armbian uc - /usr/share/backgrounds/armbian/armbian-full-under-construction-3840-2160.jpg + /usr/share/backgrounds/armbian/armbian-full-undeer-construction-3840-2160.jpg zoom #ffffff #000000 diff --git a/tools/modules/desktops/branding/icons/icon-armbian-config-penguin.png b/tools/modules/desktops/branding/icons/icon-armbian-config-penguin.png deleted file mode 100644 index 8682e6f6e..000000000 Binary files a/tools/modules/desktops/branding/icons/icon-armbian-config-penguin.png and /dev/null differ diff --git a/tools/modules/desktops/branding/icons/icon-menu-basic-white-square.png b/tools/modules/desktops/branding/icons/icon-menu-basic-white-square.png deleted file mode 100644 index 4217e0797..000000000 Binary files a/tools/modules/desktops/branding/icons/icon-menu-basic-white-square.png and /dev/null differ diff --git a/tools/modules/desktops/branding/icons/icon-menu-black.png b/tools/modules/desktops/branding/icons/icon-menu-black.png deleted file mode 100644 index 6c73cd049..000000000 Binary files a/tools/modules/desktops/branding/icons/icon-menu-black.png and /dev/null differ diff --git a/tools/modules/desktops/branding/icons/icon-menu-dark-blue.png b/tools/modules/desktops/branding/icons/icon-menu-dark-blue.png deleted file mode 100644 index d0e612c8c..000000000 Binary files a/tools/modules/desktops/branding/icons/icon-menu-dark-blue.png and /dev/null differ diff --git a/tools/modules/desktops/branding/icons/icon-menu-dark-gray.png b/tools/modules/desktops/branding/icons/icon-menu-dark-gray.png deleted file mode 100644 index 97e9ccdd0..000000000 Binary files a/tools/modules/desktops/branding/icons/icon-menu-dark-gray.png and /dev/null differ diff --git a/tools/modules/desktops/branding/icons/icon-menu-green.png b/tools/modules/desktops/branding/icons/icon-menu-green.png deleted file mode 100644 index 3c1dc2a39..000000000 Binary files a/tools/modules/desktops/branding/icons/icon-menu-green.png and /dev/null differ diff --git a/tools/modules/desktops/branding/icons/icon-menu-light-grey.png b/tools/modules/desktops/branding/icons/icon-menu-light-grey.png deleted file mode 100644 index 422178b16..000000000 Binary files a/tools/modules/desktops/branding/icons/icon-menu-light-grey.png and /dev/null differ diff --git a/tools/modules/desktops/branding/icons/icon-menu-light-purple.png b/tools/modules/desktops/branding/icons/icon-menu-light-purple.png deleted file mode 100644 index 37c3568fd..000000000 Binary files a/tools/modules/desktops/branding/icons/icon-menu-light-purple.png and /dev/null differ diff --git a/tools/modules/desktops/branding/icons/icon-menu-med-blue.png b/tools/modules/desktops/branding/icons/icon-menu-med-blue.png deleted file mode 100644 index 3ec223de0..000000000 Binary files a/tools/modules/desktops/branding/icons/icon-menu-med-blue.png and /dev/null differ diff --git a/tools/modules/desktops/branding/icons/icon-menu-purple-plastic.png b/tools/modules/desktops/branding/icons/icon-menu-purple-plastic.png deleted file mode 100644 index 3544a05f8..000000000 Binary files a/tools/modules/desktops/branding/icons/icon-menu-purple-plastic.png and /dev/null differ diff --git a/tools/modules/desktops/branding/icons/icon-menu-purple.png b/tools/modules/desktops/branding/icons/icon-menu-purple.png deleted file mode 100644 index 5f89659c0..000000000 Binary files a/tools/modules/desktops/branding/icons/icon-menu-purple.png and /dev/null differ diff --git a/tools/modules/desktops/branding/icons/icon-menu-red.png b/tools/modules/desktops/branding/icons/icon-menu-red.png deleted file mode 100644 index f9c656bfb..000000000 Binary files a/tools/modules/desktops/branding/icons/icon-menu-red.png and /dev/null differ diff --git a/tools/modules/desktops/branding/icons/icon-menu-white.png b/tools/modules/desktops/branding/icons/icon-menu-white.png deleted file mode 100644 index e39cc65fd..000000000 Binary files a/tools/modules/desktops/branding/icons/icon-menu-white.png and /dev/null differ diff --git a/tools/modules/desktops/branding/icons/scrcpy.svg b/tools/modules/desktops/branding/icons/scrcpy.svg deleted file mode 100644 index bb0eeb77f..000000000 --- a/tools/modules/desktops/branding/icons/scrcpy.svg +++ /dev/null @@ -1,885 +0,0 @@ - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/tools/modules/desktops/branding/wallpapers/Black-Red--Fractal-Abstract-Armbian-Centered_3840x2160.jpg b/tools/modules/desktops/branding/wallpapers/Black-Red--Fractal-Abstract-Armbian-Centered_3840x2160.jpg deleted file mode 100644 index ed850b82f..000000000 Binary files a/tools/modules/desktops/branding/wallpapers/Black-Red--Fractal-Abstract-Armbian-Centered_3840x2160.jpg and /dev/null differ diff --git a/tools/modules/desktops/branding/wallpapers/Black-Red--Fractal-Abstract-Armbian-Right_3840x2160.jpg b/tools/modules/desktops/branding/wallpapers/Black-Red--Fractal-Abstract-Armbian-Right_3840x2160.jpg deleted file mode 100644 index d01245507..000000000 Binary files a/tools/modules/desktops/branding/wallpapers/Black-Red--Fractal-Abstract-Armbian-Right_3840x2160.jpg and /dev/null differ diff --git a/tools/modules/desktops/branding/wallpapers/Black-Red-Abstract-Wave-Circle-Armbian-Centered_3840x2160.jpg b/tools/modules/desktops/branding/wallpapers/Black-Red-Abstract-Wave-Circle-Armbian-Centered_3840x2160.jpg deleted file mode 100644 index 297ac0695..000000000 Binary files a/tools/modules/desktops/branding/wallpapers/Black-Red-Abstract-Wave-Circle-Armbian-Centered_3840x2160.jpg and /dev/null differ diff --git a/tools/modules/desktops/branding/wallpapers/Black-and-Red-Striped-Arrow-Abstract-Armbian-Centered_3840x2160.jpg b/tools/modules/desktops/branding/wallpapers/Black-and-Red-Striped-Arrow-Abstract-Armbian-Centered_3840x2160.jpg deleted file mode 100644 index 1e4f52193..000000000 Binary files a/tools/modules/desktops/branding/wallpapers/Black-and-Red-Striped-Arrow-Abstract-Armbian-Centered_3840x2160.jpg and /dev/null differ diff --git a/tools/modules/desktops/branding/wallpapers/Black-and-Red-Striped-Arrow-Abstract_Armbian_Right_3840x2160.jpg b/tools/modules/desktops/branding/wallpapers/Black-and-Red-Striped-Arrow-Abstract_Armbian_Right_3840x2160.jpg deleted file mode 100644 index 03f05b449..000000000 Binary files a/tools/modules/desktops/branding/wallpapers/Black-and-Red-Striped-Arrow-Abstract_Armbian_Right_3840x2160.jpg and /dev/null differ diff --git a/tools/modules/desktops/branding/wallpapers/armbian-1080p-evolution.jpg b/tools/modules/desktops/branding/wallpapers/armbian-1080p-evolution.jpg deleted file mode 100644 index f615d82bf..000000000 Binary files a/tools/modules/desktops/branding/wallpapers/armbian-1080p-evolution.jpg and /dev/null differ diff --git a/tools/modules/desktops/branding/wallpapers/armbian-1080p-love.jpg b/tools/modules/desktops/branding/wallpapers/armbian-1080p-love.jpg deleted file mode 100644 index 10dffb8b0..000000000 Binary files a/tools/modules/desktops/branding/wallpapers/armbian-1080p-love.jpg and /dev/null differ diff --git a/tools/modules/desktops/branding/wallpapers/armbian-4k-penguin-SBC.jpg b/tools/modules/desktops/branding/wallpapers/armbian-4k-penguin-SBC.jpg deleted file mode 100644 index 38d51eff8..000000000 Binary files a/tools/modules/desktops/branding/wallpapers/armbian-4k-penguin-SBC.jpg and /dev/null differ diff --git a/tools/modules/desktops/branding/wallpapers/armbian-4k-penguin-minimalistic.jpg b/tools/modules/desktops/branding/wallpapers/armbian-4k-penguin-minimalistic.jpg deleted file mode 100644 index 585cd51ff..000000000 Binary files a/tools/modules/desktops/branding/wallpapers/armbian-4k-penguin-minimalistic.jpg and /dev/null differ diff --git a/tools/modules/desktops/module_appimage.sh b/tools/modules/desktops/module_appimage.sh index 10d26e604..b72138440 100644 --- a/tools/modules/desktops/module_appimage.sh +++ b/tools/modules/desktops/module_appimage.sh @@ -72,6 +72,15 @@ function module_appimage() { echo "Error: failed to install FUSE packages required for AppImages" >&2 return 1 fi + + # Ensure GL/EGL/GLES runtime is present. The Armbian Imager + # AppImage is Qt6/RHI based and dlopen()s libGLESv2.so.2 at + # startup; on a system where no DE pulled in the GL stack + # yet (or after an aggressive autopurge) the imager fails + # with "libGLESv2.so.2: cannot open shared object file". + # These are tiny and shared with every DE, so install + # unconditionally. + pkg_install libgles2 libegl1 libgl1 libgl1-mesa-dri || true # Resolve fusermount path once local fmount=$(command -v fusermount3 2>/dev/null || command -v fusermount 2>/dev/null) if [[ -z "$fmount" ]]; then diff --git a/tools/modules/desktops/module_desktop_branding.sh b/tools/modules/desktops/module_desktop_branding.sh index 930f4b635..9638dccd7 100644 --- a/tools/modules/desktops/module_desktop_branding.sh +++ b/tools/modules/desktops/module_desktop_branding.sh @@ -72,6 +72,22 @@ function module_desktop_branding() { cp "$desktop_dir/branding/pixmaps/"* /usr/share/pixmaps/armbian/ 2>/dev/null || true fi + # Distributor logo for GNOME Settings -> About / KDE Info + # Center / etc. is shipped by armbian-base-files (the same + # package that sets LOGO="armbian-logo" in /etc/os-release). + # Do NOT try to install it from here — keeping the icon + # coupled to the os-release line in one .deb is the only + # way to keep them in sync, and previous attempts to ship + # it from here all silently failed for one icon-cache + # reason or another. + + # Clean up any stray armbian-logo files left behind by + # earlier (broken) versions of this branding step. Safe + # even if armbian-base-files later ships its own. + rm -f /usr/share/icons/hicolor/scalable/apps/armbian-logo.png + rm -f /usr/share/icons/hicolor/128x128/apps/armbian-logo.png + rm -f /usr/share/icons/hicolor/256x256/apps/armbian-logo.png + # GNOME wallpaper properties if [[ -f "$desktop_dir/branding/armbian.xml" ]]; then mkdir -p /usr/share/gnome-background-properties diff --git a/tools/modules/desktops/module_desktop_yamlparse.sh b/tools/modules/desktops/module_desktop_yamlparse.sh index e19c02bea..624f19e12 100644 --- a/tools/modules/desktops/module_desktop_yamlparse.sh +++ b/tools/modules/desktops/module_desktop_yamlparse.sh @@ -9,10 +9,15 @@ module_options+=( # # Parse YAML desktop definition via Python helper -# Usage: module_desktop_yamlparse [arch] [release] +# Usage: module_desktop_yamlparse [arch] [release] [tier] # Sets: DESKTOP_PACKAGES, DESKTOP_PACKAGES_UNINSTALL, DESKTOP_PRIMARY_PKG, # DESKTOP_DM, DESKTOP_STATUS, DESKTOP_SUPPORTED, DESKTOP_DESC, -# DESKTOP_REPO_URL, DESKTOP_REPO_KEY_URL, DESKTOP_REPO_KEYRING +# DESKTOP_TIER, DESKTOP_REPO_URL, DESKTOP_REPO_KEY_URL, +# DESKTOP_REPO_KEYRING +# +# tier defaults to 'minimal' when omitted, so callers that only need +# the primary package or display manager (status checks, listing) can +# pass just the de_name without thinking about tiers. # function module_desktop_yamlparse() { local de="$1" @@ -20,19 +25,23 @@ function module_desktop_yamlparse() { local parser="${desktops_dir}/scripts/parse_desktop_yaml.py" local arch="${2:-$(dpkg --print-architecture)}" local release="${3:-$DISTROID}" + local tier="${4:-minimal}" case "$de" in help|"") - echo "Usage: module_desktop_yamlparse [arch] [release]" + echo "Usage: module_desktop_yamlparse [arch] [release] [tier]" echo "" echo "Parse a desktop YAML definition and set package variables." echo "Variables set: DESKTOP_PACKAGES, DESKTOP_PACKAGES_UNINSTALL," echo " DESKTOP_PRIMARY_PKG, DESKTOP_DM, DESKTOP_STATUS," - echo " DESKTOP_SUPPORTED, DESKTOP_DESC, DESKTOP_REPO_*" + echo " DESKTOP_SUPPORTED, DESKTOP_DESC, DESKTOP_TIER, DESKTOP_REPO_*" + echo "" + echo "tier is one of minimal|mid|full, defaults to minimal." echo "" echo "Examples:" echo " module_desktop_yamlparse xfce" echo " module_desktop_yamlparse kde-neon arm64 noble" + echo " module_desktop_yamlparse xfce arm64 trixie full" return 0 ;; *) @@ -49,13 +58,14 @@ function module_desktop_yamlparse() { DESKTOP_STATUS="" DESKTOP_SUPPORTED="" DESKTOP_DESC="" + DESKTOP_TIER="" DESKTOP_REPO_URL="" DESKTOP_REPO_KEY_URL="" DESKTOP_REPO_KEYRING="" local _output - _output=$(python3 "$parser" "$yaml_dir" "$de" "$release" "$arch" 2>&1) || { - echo "Error: failed to parse YAML for '${de}': ${_output}" >&2 + _output=$(python3 "$parser" "$yaml_dir" "$de" "$release" "$arch" --tier "$tier" 2>&1) || { + echo "Error: failed to parse YAML for '${de}' at tier '${tier}': ${_output}" >&2 return 1 } eval "$_output" || return 1 diff --git a/tools/modules/desktops/module_desktops.sh b/tools/modules/desktops/module_desktops.sh index 6005f9ba2..7f9139c63 100644 --- a/tools/modules/desktops/module_desktops.sh +++ b/tools/modules/desktops/module_desktops.sh @@ -2,19 +2,24 @@ module_options+=( ["module_desktops,author"]="@igorpecovnik" ["module_desktops,feature"]="module_desktops" ["module_desktops,desc"]="Install and manage desktop environments (YAML-driven)" - ["module_desktops,example"]="install remove disable enable status auto manual login supported installed help" + ["module_desktops,example"]="install remove disable enable status auto manual login supported installed help upgrade downgrade tier at-tier set-tier" ["module_desktops,status"]="Active" ["module_desktops,arch"]="" - ["module_desktops,help_install"]="Install desktop (de=name)" + ["module_desktops,help_install"]="Install desktop (de=name tier=minimal|mid|full)" ["module_desktops,help_remove"]="Remove desktop (de=name)" ["module_desktops,help_disable"]="Disable display manager" ["module_desktops,help_enable"]="Enable display manager" - ["module_desktops,help_status"]="Check if installed (de=name)" + ["module_desktops,help_status"]="Check if installed and at which tier (de=name)" ["module_desktops,help_auto"]="Enable auto-login (de=name)" ["module_desktops,help_manual"]="Disable auto-login (de=name)" ["module_desktops,help_login"]="Check auto-login status (de=name)" ["module_desktops,help_supported"]="JSON list or check one (de=name arch=X release=Y)" ["module_desktops,help_installed"]="Returns 0 if any desktop is installed (no de=)" + ["module_desktops,help_upgrade"]="Upgrade installed desktop to a higher tier (de=name tier=mid|full)" + ["module_desktops,help_downgrade"]="Downgrade installed desktop to a lower tier (de=name tier=minimal|mid)" + ["module_desktops,help_tier"]="Print the installed tier of a desktop, or 'not installed' (de=name)" + ["module_desktops,help_at-tier"]="Silent gate: exit 0 if a desktop is installed AND at the given tier (de=name tier=X)" + ["module_desktops,help_set-tier"]="Move installed desktop to a target tier; auto-detects upgrade vs downgrade (de=name tier=X)" ) # @@ -32,12 +37,14 @@ function module_desktops() { local de="" local query_arch="" local query_release="" + local tier="" local selected for selected in "${@:2}"; do IFS='=' read -r -a split <<< "${selected}" [[ "${split[0]}" == "de" ]] && de="${split[1]}" [[ "${split[0]}" == "arch" ]] && query_arch="${split[1]}" [[ "${split[0]}" == "release" ]] && query_release="${split[1]}" + [[ "${split[0]}" == "tier" ]] && tier="${split[1]}" done local commands @@ -52,13 +59,30 @@ function module_desktops() { return 1 fi + # tier= is required. The YAML schema has no flat default + # packages list anymore — every install picks one of + # minimal/mid/full and the parser refuses to run without + # --tier. Reject early with a clear message instead of + # letting the parser bail with a generic usage error. + if [[ -z "$tier" ]]; then + echo "Error: specify tier=minimal|mid|full" >&2 + return 1 + fi + case "$tier" in + minimal|mid|full) ;; + *) + echo "Error: invalid tier '${tier}', must be one of minimal|mid|full" >&2 + return 1 + ;; + esac + local user user=$(module_desktop_getuser) || return 1 - module_desktop_yamlparse "$de" || return 1 + module_desktop_yamlparse "$de" "$(dpkg --print-architecture)" "$DISTROID" "$tier" || return 1 if [[ -z "$DESKTOP_PACKAGES" || -z "$DESKTOP_PRIMARY_PKG" ]]; then - echo "Error: YAML definition for '${de}' has no packages" >&2 + echo "Error: YAML definition for '${de}' tier '${tier}' has no packages" >&2 return 1 fi @@ -78,15 +102,65 @@ function module_desktops() { # update package list pkg_update - # install packages - pkg_install -o Dpkg::Options::="--force-confold" ${DESKTOP_PACKAGES} + # Reset the install tracker before invoking pkg_install. pkg_install + # does an `apt-get -s install` dry-run and appends the resulting + # list of packages-to-be-newly-installed to ACTUALLY_INSTALLED. + # We persist this list below so that uninstall can remove the + # exact set we added without touching pre-existing packages + # (#799 design — restored after #815 dropped the persistence). + ACTUALLY_INSTALLED=() + + # install packages. Bail out on failure: half-installing + # a desktop and then flipping default.target to graphical + # leaves the next boot pinned to a graphical target with + # no working DM, which is a black-screen regression. + if ! pkg_install -o Dpkg::Options::="--force-confold" ${DESKTOP_PACKAGES}; then + echo "Error: ${de} package install failed; aborting before any system state is changed" >&2 + return 1 + fi # install and register display manager if [[ -n "$DESKTOP_DM" && "$DESKTOP_DM" != "none" ]]; then - pkg_install -o Dpkg::Options::="--force-confold" "$DESKTOP_DM" + if ! pkg_install -o Dpkg::Options::="--force-confold" "$DESKTOP_DM"; then + echo "Error: ${DESKTOP_DM} install failed; aborting before flipping systemd target" >&2 + return 1 + fi command -v "$DESKTOP_DM" > /etc/X11/default-display-manager 2>/dev/null || true fi + # Armbian-only branding extras: install only when the Armbian + # apt source is configured. armbian-plymouth-theme lives in + # Armbian's own repo; on a non-Armbian system the apt install + # would hard-fail with "Unable to locate package" and abort + # the entire desktop install. Keep this gated and additive so + # the rest of the desktop install path stays distro-agnostic. + # Match either the legacy single-line .list file or the modern + # deb822 .sources file. + if [[ -f /etc/apt/sources.list.d/armbian.list \ + || -f /etc/apt/sources.list.d/armbian.sources ]]; then + pkg_install -o Dpkg::Options::="--force-confold" armbian-plymouth-theme || \ + echo "Warning: armbian-plymouth-theme not installed (package not found in armbian repo)" >&2 + fi + + # Save the install manifest for uninstall to consume. + # Don't truncate an existing manifest if this run added nothing + # new (e.g. a re-install of an already-installed DE at the + # same tier) — keeping the previous manifest is more useful + # than overwriting it with an empty file that would make + # uninstall a no-op. + mkdir -p /etc/armbian/desktop + if [[ ${#ACTUALLY_INSTALLED[@]} -gt 0 ]]; then + printf '%s\n' "${ACTUALLY_INSTALLED[@]}" > "/etc/armbian/desktop/${de}.packages" + debug_log "module_desktops install: wrote ${#ACTUALLY_INSTALLED[@]} packages to /etc/armbian/desktop/${de}.packages" + fi + # Always write the tier marker file. This is the source of + # truth for `module_desktops status` and for the upgrade / + # downgrade commands' "what's currently installed" check. + # Written even when ACTUALLY_INSTALLED is empty (re-install + # at the same tier) so the marker stays accurate. + printf '%s\n' "$tier" > "/etc/armbian/desktop/${de}.tier" + debug_log "module_desktops install: wrote tier=${tier} to /etc/armbian/desktop/${de}.tier" + # remove unwanted packages if [[ -n "$DESKTOP_PACKAGES_UNINSTALL" ]]; then apt-get remove -y --purge ${DESKTOP_PACKAGES_UNINSTALL} 2>/dev/null || true @@ -95,9 +169,6 @@ function module_desktops() { # install branding module_desktop_branding "$de" - # install Armbian Imager AppImage - module_appimage install app=armbian-imager - # add user to desktop groups for group in sudo netdev audio video dialout plugdev input bluetooth systemd-journal ssh; do usermod -aG "$group" "$user" 2>/dev/null || true @@ -115,14 +186,22 @@ function module_desktops() { # update skel to existing users module_update_skel install - # display manager and auto-login (skip in containers) + # display manager and auto-login (skip in containers). + # Only flip default.target to graphical AFTER the DM has + # actually started — if the start fails, the next boot + # would otherwise pin to graphical.target with a broken + # DM and the user gets a black screen. if ! _desktop_in_container; then for dm in gdm3 lightdm sddm; do systemctl is-active --quiet "$dm" 2>/dev/null && systemctl stop "$dm" 2>/dev/null done - systemctl start display-manager 2>/dev/null || \ - systemctl start "$DESKTOP_DM" 2>/dev/null || true - module_desktops auto de="$de" + if systemctl start display-manager 2>/dev/null \ + || systemctl start "$DESKTOP_DM" 2>/dev/null; then + systemctl set-default graphical.target 2>/dev/null || true + module_desktops auto de="$de" + else + echo "Warning: ${DESKTOP_DM} did not start; leaving default.target unchanged" >&2 + fi fi unset _DESKTOPS_INSTALLED_CACHE @@ -136,28 +215,81 @@ function module_desktops() { return 1 fi - module_desktop_yamlparse "$de" || return 1 + # Read the installed tier from the marker file so the + # YAML fallback (when the manifest is missing) walks the + # right tier's package list. Default to 'minimal' if no + # marker exists, which is the safest fallback for the + # pre-tier era. + local installed_tier="minimal" + if [[ -f "/etc/armbian/desktop/${de}.tier" ]]; then + installed_tier=$(< "/etc/armbian/desktop/${de}.tier") + fi + module_desktop_yamlparse "$de" "$(dpkg --print-architecture)" "$DISTROID" "$installed_tier" || return 1 # disable auto-login module_desktops manual de="$de" 2>/dev/null - # stop display manager + # Stop display manager and switch the default systemd + # target back to multi-user. Without this step, the next + # boot still tries to reach graphical.target — but the + # display manager is about to be purged below, so the + # system arrives at graphical.target with no DM, no + # getty@tty1 (it Conflicts= with display-manager), and + # the user gets a black tty1 with no login prompt. + # Switching to multi-user.target now means the next boot + # brings up the regular console login regardless. + # + # Isolate to multi-user.target on the running session so + # the user gets a console prompt on tty1 immediately + # after the uninstall, without needing to reboot first. + # Starting getty@tty1.service on its own does not work + # while graphical.target is still active, hence isolate. + # isolate is destructive (kills any open GUI sessions), + # but we are tearing down the GUI anyway. if ! _desktop_in_container; then systemctl stop display-manager 2>/dev/null || true + systemctl set-default multi-user.target 2>/dev/null || true + systemctl isolate multi-user.target 2>/dev/null || true fi - # remove display manager - if [[ -n "$DESKTOP_DM" && "$DESKTOP_DM" != "none" ]]; then - pkg_remove "$DESKTOP_DM" + # Remove the exact set of packages that were newly installed by + # the install path. This list was captured at install time from + # `apt-get -s install` and saved to /etc/armbian/desktop/.packages + # by the install branch below — see #799 for the original design. + # It correctly excludes packages that were already on the system + # before the desktop install (so we don't yank shared deps). + local desktop_pkg_file="/etc/armbian/desktop/${de}.packages" + local to_remove=() pkg + if [[ -f "$desktop_pkg_file" ]]; then + while IFS= read -r pkg; do + [[ -z "$pkg" ]] && continue + to_remove+=("$pkg") + done < "$desktop_pkg_file" + else + # Fallback for desktops installed before the tracking file + # existed: walk the YAML package list, keeping only what's + # currently installed. This is less precise (it can keep + # packages the system had pre-install) but it's the best we + # can do without the manifest. + echo "Warning: no install manifest at ${desktop_pkg_file}, falling back to YAML package list" >&2 + for pkg in $DESKTOP_PACKAGES $DESKTOP_DM; do + [[ "$pkg" == "none" ]] && continue + if dpkg-query -W -f='${Status}\n' "$pkg" 2>/dev/null | grep -q "install ok installed"; then + to_remove+=("$pkg") + fi + done fi - # remove primary DE package (autopurge handles dependencies) - if [[ -n "$DESKTOP_PRIMARY_PKG" ]]; then - pkg_remove "$DESKTOP_PRIMARY_PKG" + if [[ ${#to_remove[@]} -gt 0 ]]; then + pkg_remove "${to_remove[@]}" fi + rm -f "$desktop_pkg_file" "/etc/armbian/desktop/${de}.tier" - # remove AppImages - module_appimage remove app=armbian-imager + # Reclaim disk space: clear apt's downloaded .deb cache. A full + # DE removal frees hundreds of MB of installed files; the + # matching .deb archives in /var/cache/apt/archives are no + # longer needed and would otherwise just sit there. + pkg_clean unset _DESKTOPS_INSTALLED_CACHE echo "${de} removed." @@ -176,13 +308,20 @@ function module_desktops() { ;; "${commands[4]}") - # status + # status — pure exit-code query. Returns 0 if the desktop + # is installed, 1 if not. SILENT on both paths: this + # command runs from every dialog menu entry's `condition` + # field, dozens of times per menu render, and any stdout + # output leaks into the dialog. To get the installed + # tier name, use the `tier` command instead. if [[ -z "$de" ]]; then echo "Error: specify de=name" >&2 return 1 fi module_desktop_yamlparse "$de" || return 1 - [[ -n "$DESKTOP_PRIMARY_PKG" ]] && dpkg -l "$DESKTOP_PRIMARY_PKG" 2>/dev/null | grep -q "^ii" && return 0 + if [[ -n "$DESKTOP_PRIMARY_PKG" ]] && dpkg -l "$DESKTOP_PRIMARY_PKG" 2>/dev/null | grep -q "^ii"; then + return 0 + fi return 1 ;; @@ -196,13 +335,42 @@ function module_desktops() { case "$DESKTOP_DM" in gdm3) mkdir -p /etc/gdm3 - local gdm_conf="/etc/gdm3/custom.conf" - [[ "$DISTROID" == "trixie" || "$DISTROID" == "forky" ]] && gdm_conf="/etc/gdm3/daemon.conf" - cat > "$gdm_conf" <<- EOF - [daemon] - AutomaticLoginEnable = true - AutomaticLogin = ${user} - EOF + # gdm3 has NO conf.d drop-in support upstream or + # in Debian/Ubuntu patches: it loads exactly one + # file. So we have to edit it in place. The file + # is /etc/gdm3/daemon.conf on Debian (any release) + # and /etc/gdm3/custom.conf on Ubuntu — branch on + # /etc/os-release ID=, not on release codename. + local gdm_conf="/etc/gdm3/daemon.conf" + if [[ -f /etc/os-release ]] && grep -q '^ID=ubuntu' /etc/os-release; then + gdm_conf="/etc/gdm3/custom.conf" + fi + # Idempotent in-place edit of the [daemon] section. + # Preserves any other sections / settings the user + # may have customized. + if [[ ! -f "$gdm_conf" ]]; then + cat > "$gdm_conf" <<- EOF + [daemon] + AutomaticLoginEnable = true + AutomaticLogin = ${user} + EOF + else + # Make sure [daemon] section exists. + grep -q '^\[daemon\]' "$gdm_conf" || \ + printf '\n[daemon]\n' >> "$gdm_conf" + # Update or insert AutomaticLoginEnable. + if grep -q '^AutomaticLoginEnable' "$gdm_conf"; then + sed -i 's/^AutomaticLoginEnable.*/AutomaticLoginEnable = true/' "$gdm_conf" + else + sed -i '/^\[daemon\]/a AutomaticLoginEnable = true' "$gdm_conf" + fi + # Update or insert AutomaticLogin. + if grep -q '^AutomaticLogin\b' "$gdm_conf"; then + sed -i "s/^AutomaticLogin\b.*/AutomaticLogin = ${user}/" "$gdm_conf" + else + sed -i "/^\[daemon\]/a AutomaticLogin = ${user}" "$gdm_conf" + fi + fi ;; sddm) mkdir -p /etc/sddm.conf.d @@ -233,7 +401,15 @@ function module_desktops() { module_desktop_yamlparse "$de" || return 1 case "$DESKTOP_DM" in - gdm3) sed -i 's/AutomaticLoginEnable = true/AutomaticLoginEnable = false/' /etc/gdm3/custom.conf /etc/gdm3/daemon.conf 2>/dev/null ;; + gdm3) + # Match any whitespace around the '=' so we don't + # care whether the file has 'Enable=true' or + # 'Enable = true'. + for f in /etc/gdm3/custom.conf /etc/gdm3/daemon.conf; do + [[ -f "$f" ]] || continue + sed -i -E 's/^(AutomaticLoginEnable)[[:space:]]*=.*/\1 = false/' "$f" + done + ;; sddm) rm -f /etc/sddm.conf.d/autologin.conf ;; lightdm) rm -f /etc/lightdm/lightdm.conf.d/22-armbian-autologin.conf ;; esac @@ -246,7 +422,18 @@ function module_desktops() { module_desktop_yamlparse "$de" || return 1 case "$DESKTOP_DM" in - gdm3) grep -qE 'AutomaticLoginEnable\s*=\s*true' /etc/gdm3/custom.conf /etc/gdm3/daemon.conf 2>/dev/null && return 0 ;; + gdm3) + # Anchor at the line start so the stock custom.conf + # template's commented sample line + # # AutomaticLoginEnable = true + # does not match. The previous unanchored regex + # returned 0 (autologin enabled) on every fresh + # noble install where the user had never touched + # autologin, because the substring 'AutomaticLoginEnable + # = true' was present inside the comment. + grep -qE '^AutomaticLoginEnable[[:space:]]*=[[:space:]]*true' \ + /etc/gdm3/custom.conf /etc/gdm3/daemon.conf 2>/dev/null && return 0 + ;; sddm) [[ -f /etc/sddm.conf.d/autologin.conf ]] && return 0 ;; lightdm) [[ -f /etc/lightdm/lightdm.conf.d/22-armbian-autologin.conf ]] && return 0 ;; esac @@ -301,7 +488,115 @@ function module_desktops() { "${commands[10]}") show_module_help "module_desktops" "Desktops" \ - "Examples:\n module_desktops install de=xfce\n module_desktops supported\n module_desktops supported arch=arm64 release=trixie\n module_desktops supported de=gnome" "native" + "Examples:\n module_desktops install de=xfce tier=minimal\n module_desktops install de=gnome tier=full\n module_desktops upgrade de=xfce tier=mid\n module_desktops downgrade de=xfce tier=minimal\n module_desktops status de=xfce\n module_desktops supported arch=arm64 release=trixie" "native" + ;; + + "${commands[11]}") + # upgrade — install the delta from the currently + # installed tier up to a higher target tier. Refuses + # to "upgrade" to the same or a lower tier (use the + # downgrade command for that). + _module_desktops_change_tier upgrade "$de" "$tier" + return $? + ;; + + "${commands[12]}") + # downgrade — remove the delta from the currently + # installed tier down to a lower target tier. The + # removable set is intersected with the install + # manifest so packages the user installed manually + # (outside the desktop install path) are never + # touched. + _module_desktops_change_tier downgrade "$de" "$tier" + return $? + ;; + + "${commands[13]}") + # tier — value-returning getter, separate from + # `status` which is a silent exit-code query. Prints + # the installed tier name (minimal/mid/full) on + # stdout, or "not installed" if no marker file + # exists. Returns 0 if installed, 1 if not. + # Use this from the CLI when you want the actual + # tier; use `status` from menu condition gates. + if [[ -z "$de" ]]; then + echo "Error: specify de=name" >&2 + return 1 + fi + module_desktop_yamlparse "$de" || return 1 + if [[ -n "$DESKTOP_PRIMARY_PKG" ]] && dpkg -l "$DESKTOP_PRIMARY_PKG" 2>/dev/null | grep -q "^ii"; then + if [[ -f "/etc/armbian/desktop/${de}.tier" ]]; then + cat "/etc/armbian/desktop/${de}.tier" + else + echo "minimal" + fi + return 0 + fi + echo "not installed" + return 1 + ;; + + "${commands[14]}") + # at-tier — silent gate. Exit 0 if the desktop is + # installed AND the current tier marker matches the + # target. Used by the dialog menu's `condition` field + # to hide the "Change to " entry that matches + # the currently-installed tier. Pure exit-code query; + # no stdout output, just like `status`. + if [[ -z "$de" || -z "$tier" ]]; then + echo "Error: specify de=name tier=X" >&2 + return 1 + fi + module_desktop_yamlparse "$de" || return 1 + [[ -n "$DESKTOP_PRIMARY_PKG" ]] || return 1 + dpkg -l "$DESKTOP_PRIMARY_PKG" 2>/dev/null | grep -q "^ii" || return 1 + local current="minimal" + [[ -f "/etc/armbian/desktop/${de}.tier" ]] && current=$(< "/etc/armbian/desktop/${de}.tier") + [[ "$current" == "$tier" ]] + ;; + + "${commands[15]}") + # set-tier — direction-agnostic tier change. Reads + # the current tier from the marker file and dispatches + # to upgrade or downgrade based on which is higher. + # Used by the dialog menu's "Change to " entries + # so a single button can either upgrade or downgrade + # without the menu having to know the current state. + if [[ -z "$de" ]]; then + echo "Error: specify de=name" >&2 + return 1 + fi + if [[ -z "$tier" ]]; then + echo "Error: specify tier=minimal|mid|full" >&2 + return 1 + fi + case "$tier" in + minimal|mid|full) ;; + *) + echo "Error: invalid tier '${tier}'" >&2 + return 1 + ;; + esac + if [[ ! -f "/etc/armbian/desktop/${de}.tier" ]]; then + echo "Error: ${de} is not installed" >&2 + return 1 + fi + local current + current=$(< "/etc/armbian/desktop/${de}.tier") + if [[ "$current" == "$tier" ]]; then + echo "${de} is already at tier '${tier}', nothing to do." + return 0 + fi + # Numeric ordering for direction detection. + local _tier_n_minimal=1 _tier_n_mid=2 _tier_n_full=3 + local _cur_var="_tier_n_${current}" + local _tgt_var="_tier_n_${tier}" + if [[ "${!_tgt_var}" -gt "${!_cur_var}" ]]; then + _module_desktops_change_tier upgrade "$de" "$tier" + else + _module_desktops_change_tier downgrade "$de" "$tier" + fi + return $? ;; *) @@ -309,3 +604,171 @@ function module_desktops() { ;; esac } + +# +# _module_desktops_change_tier +# +# Move an installed desktop from its current tier to a target tier. +# upgrade installs the delta of (target - current); downgrade removes +# the delta of (current - target). Refuses wrong-direction calls. +# +# The downgrade path intersects the removable set with the install +# manifest at /etc/armbian/desktop/.packages, so any package the +# user installed manually after the desktop install (and which +# happens to also be named in the YAML) is never touched. +# +_module_desktops_change_tier() { + local direction="$1" + local de="$2" + local target="$3" + + if [[ -z "$de" ]]; then + echo "Error: specify de=name" >&2 + return 1 + fi + if [[ -z "$target" ]]; then + echo "Error: specify tier=minimal|mid|full" >&2 + return 1 + fi + case "$target" in + minimal|mid|full) ;; + *) + echo "Error: invalid tier '${target}', must be one of minimal|mid|full" >&2 + return 1 + ;; + esac + + # Numeric ordering for comparison. + local _tier_n_minimal=1 _tier_n_mid=2 _tier_n_full=3 + local _target_n_var="_tier_n_${target}" + local target_n="${!_target_n_var}" + + if [[ ! -f "/etc/armbian/desktop/${de}.tier" ]]; then + echo "Error: ${de} is not installed (no tier marker at /etc/armbian/desktop/${de}.tier)" >&2 + return 1 + fi + local current + current=$(< "/etc/armbian/desktop/${de}.tier") + local _current_n_var="_tier_n_${current}" + local current_n="${!_current_n_var}" + if [[ -z "$current_n" ]]; then + echo "Error: unrecognised tier '${current}' in /etc/armbian/desktop/${de}.tier" >&2 + return 1 + fi + + if [[ "$current" == "$target" ]]; then + echo "${de} is already at tier '${target}', nothing to do." + return 0 + fi + if [[ "$direction" == "upgrade" && "$target_n" -lt "$current_n" ]]; then + echo "Error: cannot upgrade ${de} from '${current}' to '${target}' (target is lower); use 'downgrade' instead" >&2 + return 1 + fi + if [[ "$direction" == "downgrade" && "$target_n" -gt "$current_n" ]]; then + echo "Error: cannot downgrade ${de} from '${current}' to '${target}' (target is higher); use 'upgrade' instead" >&2 + return 1 + fi + + # Parse the YAML twice — once at current tier, once at target. + # Save and restore the parser output variables across the two + # calls so the install path's globals are not stomped on. + local _arch="$(dpkg --print-architecture)" + local _release="$DISTROID" + + module_desktop_yamlparse "$de" "$_arch" "$_release" "$current" || return 1 + local current_arr=() + read -ra current_arr <<< "$DESKTOP_PACKAGES" + + module_desktop_yamlparse "$de" "$_arch" "$_release" "$target" || return 1 + local target_arr=() + read -ra target_arr <<< "$DESKTOP_PACKAGES" + + # Compute the set difference. Use awk with two file arguments + # (stdin redirection from process substitution), reading the + # arrays one element per line via printf so each entry is its + # own awk record. Plain '$current_pkgs' would put every package + # on one line and break the comparison. + local to_install=() + local to_remove=() + if [[ "$direction" == "upgrade" ]]; then + # packages in target but not in current + while IFS= read -r pkg; do + [[ -n "$pkg" ]] && to_install+=("$pkg") + done < <(awk 'NR==FNR{a[$0]=1; next} !($0 in a)' \ + <(printf '%s\n' "${current_arr[@]}") \ + <(printf '%s\n' "${target_arr[@]}")) + else + # downgrade: packages in current but not in target, + # intersected with the install manifest so user-installed + # packages are never touched. + local manifest_pkgs=() + if [[ -f "/etc/armbian/desktop/${de}.packages" ]]; then + while IFS= read -r pkg; do + [[ -n "$pkg" ]] && manifest_pkgs+=("$pkg") + done < "/etc/armbian/desktop/${de}.packages" + fi + local candidates=() + while IFS= read -r pkg; do + [[ -n "$pkg" ]] && candidates+=("$pkg") + done < <(awk 'NR==FNR{a[$0]=1; next} !($0 in a)' \ + <(printf '%s\n' "${target_arr[@]}") \ + <(printf '%s\n' "${current_arr[@]}")) + # intersect candidates with manifest_pkgs + local manifest_set=" ${manifest_pkgs[*]} " + for pkg in "${candidates[@]}"; do + if [[ "$manifest_set" == *" $pkg "* ]]; then + to_remove+=("$pkg") + fi + done + fi + + # Apply the change. + if [[ "$direction" == "upgrade" ]]; then + if [[ ${#to_install[@]} -eq 0 ]]; then + echo "${de}: nothing to install for upgrade ${current} -> ${target}" + else + echo "Upgrading ${de} from ${current} to ${target} (${#to_install[@]} new packages)" + ACTUALLY_INSTALLED=() + if ! pkg_install -o Dpkg::Options::="--force-confold" "${to_install[@]}"; then + echo "Error: pkg_install failed during upgrade" >&2 + return 1 + fi + # append the newly-installed packages to the manifest + if [[ ${#ACTUALLY_INSTALLED[@]} -gt 0 ]]; then + mkdir -p /etc/armbian/desktop + printf '%s\n' "${ACTUALLY_INSTALLED[@]}" >> "/etc/armbian/desktop/${de}.packages" + fi + fi + else + if [[ ${#to_remove[@]} -eq 0 ]]; then + echo "${de}: nothing to remove for downgrade ${current} -> ${target}" + else + echo "Downgrading ${de} from ${current} to ${target} (${#to_remove[@]} packages to remove)" + if ! pkg_remove "${to_remove[@]}"; then + echo "Error: pkg_remove failed during downgrade" >&2 + return 1 + fi + # rewrite the manifest, removing the just-removed packages + if [[ -f "/etc/armbian/desktop/${de}.packages" ]]; then + local removed_set=" ${to_remove[*]} " + local kept=() + while IFS= read -r pkg; do + [[ -z "$pkg" ]] && continue + if [[ "$removed_set" != *" $pkg "* ]]; then + kept+=("$pkg") + fi + done < "/etc/armbian/desktop/${de}.packages" + if [[ ${#kept[@]} -gt 0 ]]; then + printf '%s\n' "${kept[@]}" > "/etc/armbian/desktop/${de}.packages" + else + rm -f "/etc/armbian/desktop/${de}.packages" + fi + fi + fi + fi + + # Update the tier marker + printf '%s\n' "$target" > "/etc/armbian/desktop/${de}.tier" + debug_log "module_desktops ${direction}: ${de} ${current} -> ${target}" + return 0 +} diff --git a/tools/modules/desktops/module_update_skel.sh b/tools/modules/desktops/module_update_skel.sh index dea79559c..8fc06a492 100644 --- a/tools/modules/desktops/module_update_skel.sh +++ b/tools/modules/desktops/module_update_skel.sh @@ -16,25 +16,46 @@ function module_update_skel() { case "$1" in "${commands[0]}") - # install - copy skel to all regular users + # install - copy skel into every regular user's home, then + # fix ownership of the entire home tree. + # + # Implementation note: we used to do + # cp -r --update=none /etc/skel/. "$home/" + # but '--update=none' was only added in GNU coreutils 9.3 + # (Debian bookworm ships 9.1 and rejects it). The + # alternative '-n' / '--no-clobber' is available on both, + # but on coreutils 9.2+ '-n' prints a diagnostic and + # exits nonzero whenever it skips a file — which on a + # normal repeat invocation is every file. Neither flag is + # portable across bookworm and noble simultaneously. + # + # Use a per-file find loop instead: walk /etc/skel and + # copy each entry only if it doesn't already exist at + # the destination. find walks parents before children, so + # directories are created and chowned before any of their + # contents arrive. + # + # After the per-file copy, chown -R the entire home + # anyway. This is a safety net for root-owned files that + # other package postinst scripts leak into the user's + # home (caja, nemo, gnome-keyring and others all do this + # on first install) — without the recursive chown, caja + # and nemo refuse to start on first login complaining + # that ~/.config/{caja,nemo} are not writable. getent passwd | while IFS=: read -r username x uid gid gecos home shell; do if [ ! -d "$home" ] || [ "$username" == 'root' ] || [ "$uid" -lt 1000 ] || [ "$uid" -ge 65534 ]; then continue fi - # copy new files only, then fix ownership of copied files find /etc/skel -mindepth 1 | while read -r src; do local dst="$home/${src#/etc/skel/}" - if [ ! -e "$dst" ]; then - if [ -d "$src" ]; then - mkdir -p "$dst" - else - mkdir -p "$(dirname "$dst")" - cp "$src" "$dst" - fi - chown "$uid:$gid" "$dst" + if [ -d "$src" ]; then + [ -d "$dst" ] || mkdir "$dst" + elif [ ! -e "$dst" ]; then + cp "$src" "$dst" fi done + chown -R "$uid:$gid" "$home/" done ;; "${commands[1]}") diff --git a/tools/modules/desktops/postinst/gnome.sh b/tools/modules/desktops/postinst/gnome.sh index beaed0a90..d16129090 100644 --- a/tools/modules/desktops/postinst/gnome.sh +++ b/tools/modules/desktops/postinst/gnome.sh @@ -39,6 +39,17 @@ system-db:local" >> $profile dconf update +# Clean up any leftover gnome-ubuntu-panel.desktop hider stub. An +# earlier version of this postinst dropped a NoDisplay=true stub at +# /usr/local/share/applications/gnome-ubuntu-panel.desktop to hide +# Canonical's broken-iconed "Ubuntu" panel entry from the GNOME app +# grid. The stub turned out to break gnome-control-center entirely: +# the panel is a gnome-control-center compiled-in descriptor, not a +# normal app launcher, and on startup the panel-walk asserts on the +# stub being a valid desktop file, abort()s, and Settings will not +# launch at all. Strip the stub if it exists. +rm -f /usr/local/share/applications/gnome-ubuntu-panel.desktop + # Let NetworkManager coexist with systemd-networkd (only if networkd is active) if command -v NetworkManager > /dev/null 2>&1 && systemctl is-active --quiet systemd-networkd 2>/dev/null; then mkdir -p /etc/NetworkManager/conf.d diff --git a/tools/modules/desktops/scripts/parse_desktop_yaml.py b/tools/modules/desktops/scripts/parse_desktop_yaml.py index b76ffc6f0..a3b09fb0d 100755 --- a/tools/modules/desktops/scripts/parse_desktop_yaml.py +++ b/tools/modules/desktops/scripts/parse_desktop_yaml.py @@ -2,17 +2,48 @@ """ Parse desktop YAML definitions and output bash-compatible variables. -Usage: - parse_desktop_yaml.py +Tier model +---------- +Every DE YAML defines its packages under a `tiers:` map with three tiers, +in order of inclusion: minimal -> mid -> full. Each tier is the union of +itself plus all lower tiers, so installing 'full' implies 'mid' implies +'minimal'. Tiers are mandatory; there is no flat top-level `packages:` +list anymore. + +common.yaml carries the per-tier defaults that apply to every desktop +(branding, base apps, browser slot). Per-DE YAMLs can add packages to a +tier or remove ones inherited from common, via `tiers..packages` +and `tiers..packages_remove`. + +The literal token `browser` inside any tier resolves to the per-arch +package name from common.yaml's `browser:` map (e.g. chromium on +arm64/amd64/armhf, firefox on riscv64). + +Per-DE per-tier per-arch overrides live under `tier_overrides:`, with +the same shape as the release block. Use this to drop packages that +do not exist on a particular arch (e.g. blender/inkscape on armhf). + +Per-release deltas (architecture support, packages_remove for the +release as a whole, extra packages per release) keep their existing +top-level `releases:` block — the release filter is orthogonal to the +tier filter and applies after all tier merging is done. + +Usage +----- + parse_desktop_yaml.py --tier parse_desktop_yaml.py --list + parse_desktop_yaml.py --list-json + parse_desktop_yaml.py --primaries Output (bash eval-friendly): DESKTOP_PACKAGES="pkg1 pkg2 ..." DESKTOP_PACKAGES_UNINSTALL="pkg1 pkg2 ..." + DESKTOP_PRIMARY_PKG="xfce4" DESKTOP_DM="lightdm" DESKTOP_STATUS="supported" DESKTOP_SUPPORTED="yes" - DESKTOP_DESC="XFCE - lightweight and fast desktop" + DESKTOP_DESC="..." + DESKTOP_TIER="full" DESKTOP_REPO_URL="..." (optional, for custom repos) DESKTOP_REPO_KEY_URL="..." (optional) DESKTOP_REPO_KEYRING="..." (optional) @@ -23,6 +54,9 @@ import yaml +TIERS_IN_ORDER = ("minimal", "mid", "full") + + def shell_escape(s): """Escape characters that are special inside double-quoted shell strings.""" return str(s).replace('\\', '\\\\').replace('"', '\\"').replace('$', '\\$').replace('`', '\\`') @@ -38,25 +72,150 @@ def _as_list(node): return node if isinstance(node, list) else [] -def load_common(yaml_dir): - """Load common packages from common.yaml.""" - common_file = os.path.join(yaml_dir, "common.yaml") - if not os.path.exists(common_file): - return [] - with open(common_file) as f: - data = yaml.safe_load(f) - if not isinstance(data, dict): - print(f"Error: common.yaml must be a mapping (root object), got {type(data).__name__}", file=sys.stderr) - sys.exit(1) - pkgs = data.get("packages", []) - if not isinstance(pkgs, list): - print(f"Error: 'packages' in common.yaml must be a list, got {type(pkgs).__name__}", file=sys.stderr) +def _tiers_up_to(target): + """Return the ordered list of tier names from minimal up to and including target.""" + if target not in TIERS_IN_ORDER: + print(f"Error: invalid tier '{target}', must be one of {','.join(TIERS_IN_ORDER)}", file=sys.stderr) sys.exit(1) - return pkgs + return list(TIERS_IN_ORDER[:TIERS_IN_ORDER.index(target) + 1]) + + +def _load_yaml(path): + if not os.path.exists(path): + return {} + with open(path) as f: + data = yaml.safe_load(f) + return data if isinstance(data, dict) else {} + +def load_common(yaml_dir): + """Load common.yaml as a dict (or empty dict if missing).""" + return _load_yaml(os.path.join(yaml_dir, "common.yaml")) + + +def _merge_tier(packages, removes, source, tier): + """Merge a tier block from a YAML source into packages/removes lists. + + Each tier block looks like: + tiers: + : + packages: [...] + packages_remove: [...] # filter out from earlier tiers / common + """ + tier_block = _as_dict(_as_dict(source.get("tiers")).get(tier)) + for pkg in _as_list(tier_block.get("packages")): + if pkg not in packages: + packages.append(pkg) + for pkg in _as_list(tier_block.get("packages_remove")): + if pkg in packages: + packages.remove(pkg) + if pkg not in removes: + removes.append(pkg) + + +def _resolve_browser(packages, common, release, arch): + """Substitute the literal token `browser` with the right package per release+arch. + + The browser map in common.yaml has two layers: + + browser: + amd64: chromium # default fallback for any release on this arch + ... + bookworm: + amd64: chromium # per-release per-arch override + riscv64: firefox-esr + + Lookup order: + 1. browser.. (most specific) + 2. browser. (per-arch fallback) + 3. drop the token altogether (silently — install proceeds without + a browser rather than failing on a literal 'browser' apt name) + + The per-release layer is needed because the same arch can resolve + differently across releases: + - Debian has 'firefox-esr' but no 'firefox' + - Ubuntu's 'chromium' is a snap-shim deb that requires snapd + - 'chromium' isn't built for riscv64 in Debian or Ubuntu + """ + browser_map = _as_dict(common.get("browser")) + if "browser" not in packages: + return + # Try per-release per-arch first (browser. is a dict of arch->pkg). + release_map = _as_dict(browser_map.get(release)) + pkg = release_map.get(arch) if release_map else None + # Fall back to top-level per-arch if no per-release entry exists. Only + # consider top-level keys that are arch names — skip release-name keys + # by checking that the value is a string, not a dict. + if not pkg: + candidate = browser_map.get(arch) + if isinstance(candidate, str): + pkg = candidate + if not pkg: + # No browser defined for this combo — silently drop the token rather + # than passing 'browser' to apt and breaking the install. The dialog + # layer can warn the user separately if it cares. + packages.remove("browser") + return + idx = packages.index("browser") + packages[idx] = pkg + + +def _apply_tier_overrides(packages, source, tier, release, arch): + """Apply tier_overrides from a YAML source. + + Schema: + + tier_overrides: + : + architectures: + : + packages_remove: [...] # remove on this arch in any release + releases: + : + architectures: + : + packages_remove: [...] # remove on this release+arch combo + + Both layers are applied. Use the per-arch layer for permanent + arch-wide holes (e.g. blender always missing on armhf), and the + per-release-per-arch layer for transient holes (e.g. loupe missing + on bookworm because GNOME 43 didn't have it). + """ + tier_block = _as_dict(_as_dict(source.get("tier_overrides")).get(tier)) + + # Per-arch (any release) layer. + archs = _as_dict(tier_block.get("architectures")) + arch_block = _as_dict(archs.get(arch)) + for pkg in _as_list(arch_block.get("packages_remove")): + if pkg in packages: + packages.remove(pkg) + + # Per-release-per-arch layer. + releases = _as_dict(tier_block.get("releases")) + release_block = _as_dict(releases.get(release)) + release_archs = _as_dict(release_block.get("architectures")) + release_arch_block = _as_dict(release_archs.get(arch)) + for pkg in _as_list(release_arch_block.get("packages_remove")): + if pkg in packages: + packages.remove(pkg) + + +def _gather_de_pkgs_at_tier(de_data, tier): + """Collect just the DE-specific packages declared at a tier level (no common, no release). + + Used to identify the primary package — it has to come from the DE's own + declarations, not from common.yaml (otherwise every DE would share the + same primary). + """ + de_pkgs = [] + de_removes = [] + for t in _tiers_up_to(tier): + _merge_tier(de_pkgs, de_removes, de_data, t) + return de_pkgs -def parse_desktop(yaml_dir, de_name, release, arch): - """Parse a single desktop definition.""" + +def parse_desktop(yaml_dir, de_name, release, arch, tier): + """Parse a single desktop definition at the requested tier.""" yaml_file = os.path.join(yaml_dir, f"{de_name}.yaml") # Reject path traversal: de_name comes from CLI input on a tool that may run @@ -71,60 +230,83 @@ def parse_desktop(yaml_dir, de_name, release, arch): print(f"Error: no definition for '{de_name}'", file=sys.stderr) sys.exit(1) - with open(yaml_file) as f: - data = yaml.safe_load(f) - - if not isinstance(data, dict): + de_data = _load_yaml(yaml_file) + if not de_data: print(f"Error: invalid YAML in '{de_name}'", file=sys.stderr) sys.exit(1) - # validate package lists are actually lists - if not isinstance(data.get("packages", []), list): - print(f"Error: 'packages' must be a list in '{de_name}'", file=sys.stderr) - sys.exit(1) - - de_pkgs = data.get("packages", []) - - # common + base packages - common_pkgs = load_common(yaml_dir) - base_pkgs = common_pkgs + de_pkgs - base_uninstall = _as_list(data.get("packages_uninstall")) - - # release-specific overrides - releases = _as_dict(data.get("releases")) + common = load_common(yaml_dir) + + # 1. Walk tiers from minimal -> target. At each step, merge the + # tier's packages from common and the DE, then apply both + # common and per-DE tier_overrides for that tier. Walking + # tier_overrides in the same loop as packages means a hole + # declared at the mid tier (e.g. 'loupe' missing on bookworm) + # is honoured for ALL tiers >= mid, including full. + packages = [] + removes = [] + for t in _tiers_up_to(tier): + _merge_tier(packages, removes, common, t) + _merge_tier(packages, removes, de_data, t) + _apply_tier_overrides(packages, common, t, release, arch) + _apply_tier_overrides(packages, de_data, t, release, arch) + + # 2. Resolve the `browser` virtual token per release+arch. + _resolve_browser(packages, common, release, arch) + + # 4. Apply the orthogonal release block — packages_remove + packages + # declared per release. The release block is independent of tier. + releases = _as_dict(de_data.get("releases")) release_data = _as_dict(releases.get(release)) - - # architecture support supported_archs = _as_list(release_data.get("architectures")) is_supported = arch in supported_archs and release in releases - # merge release overrides - release_pkgs = _as_list(release_data.get("packages")) - release_remove = _as_list(release_data.get("packages_remove")) - release_uninstall = _as_list(release_data.get("packages_uninstall")) - - # combine and filter - all_pkgs = base_pkgs + release_pkgs - all_uninstall = base_uninstall + release_uninstall - final_pkgs = [p for p in all_pkgs if p not in release_remove] - - # primary package: first DE-specific package that survives release_remove. - # Must NOT come from final_pkgs[0] (that's a common.yaml package and would - # be identical across every desktop, breaking status/remove). - effective_de_pkgs = [p for p in (de_pkgs + release_pkgs) if p not in release_remove] + for pkg in _as_list(release_data.get("packages_remove")): + if pkg in packages: + packages.remove(pkg) + for pkg in _as_list(release_data.get("packages")): + if pkg not in packages: + packages.append(pkg) + + # 5. packages_uninstall is collected from minimal-tier (common + DE) + + # release-level packages_uninstall. The remove path uses this to purge + # packages that get pulled in transitively but we don't want. + uninstall = [] + for src in (common, de_data): + tier_block = _as_dict(_as_dict(src.get("tiers")).get("minimal")) + for pkg in _as_list(tier_block.get("packages_uninstall")): + if pkg not in uninstall: + uninstall.append(pkg) + for pkg in _as_list(release_data.get("packages_uninstall")): + if pkg not in uninstall: + uninstall.append(pkg) + + # 6. Primary package: first DE-specific package (not from common) that + # survived all the filters. Used by `module_desktops status` to detect + # whether the desktop is currently installed. + de_pkgs_at_tier = _gather_de_pkgs_at_tier(de_data, tier) + # filter against the same release_remove that applied to the main set + release_removes = set(_as_list(release_data.get("packages_remove"))) + # also against tier_overrides for this arch + overrides = _as_dict(_as_dict(_as_dict(de_data.get("tier_overrides")).get(tier)).get("architectures")) + arch_block = _as_dict(overrides.get(arch)) + arch_removes = set(_as_list(arch_block.get("packages_remove"))) + effective_de_pkgs = [p for p in de_pkgs_at_tier + if p not in release_removes and p not in arch_removes] primary_pkg = effective_de_pkgs[0] if effective_de_pkgs else "" # output bash variables (shell-escaped) - print(f'DESKTOP_PACKAGES="{shell_escape(" ".join(final_pkgs))}"') - print(f'DESKTOP_PACKAGES_UNINSTALL="{shell_escape(" ".join(all_uninstall))}"') + print(f'DESKTOP_PACKAGES="{shell_escape(" ".join(packages))}"') + print(f'DESKTOP_PACKAGES_UNINSTALL="{shell_escape(" ".join(uninstall))}"') print(f'DESKTOP_PRIMARY_PKG="{shell_escape(primary_pkg)}"') - print(f'DESKTOP_DM="{shell_escape(data.get("display_manager", "lightdm"))}"') - print(f'DESKTOP_STATUS="{shell_escape(data.get("status", "unsupported"))}"') + print(f'DESKTOP_DM="{shell_escape(de_data.get("display_manager", "lightdm"))}"') + print(f'DESKTOP_STATUS="{shell_escape(de_data.get("status", "unsupported"))}"') print(f'DESKTOP_SUPPORTED="{"yes" if is_supported else "no"}"') - print(f'DESKTOP_DESC="{shell_escape(data.get("description", de_name))}"') + print(f'DESKTOP_DESC="{shell_escape(de_data.get("description", de_name))}"') + print(f'DESKTOP_TIER="{shell_escape(tier)}"') # repo info - repo = _as_dict(data.get("repo")) + repo = _as_dict(de_data.get("repo")) if repo: print(f'DESKTOP_REPO_URL="{shell_escape(repo.get("url", ""))}"') print(f'DESKTOP_REPO_KEY_URL="{shell_escape(repo.get("key_url", ""))}"') @@ -134,28 +316,31 @@ def parse_desktop(yaml_dir, de_name, release, arch): def list_primaries(yaml_dir, release, arch): """Print '\\t' for every desktop, applying release overrides. - Used by `module_desktops installed` to detect whether any desktop is currently - installed without spawning one Python process per desktop. + Used by `module_desktops installed` to detect whether any desktop is + currently installed without spawning one Python process per desktop. + The primary package is computed at the minimal tier — that is enough + to identify "any tier of this DE is installed". """ + common = load_common(yaml_dir) for fname in sorted(os.listdir(yaml_dir)): if not fname.endswith(".yaml") or fname == "common.yaml": continue fpath = os.path.join(yaml_dir, fname) if not os.path.isfile(fpath): continue - with open(fpath) as f: - data = yaml.safe_load(f) - if not isinstance(data, dict): + de_data = _load_yaml(fpath) + if not de_data: continue - de_pkgs = data.get("packages", []) - if not isinstance(de_pkgs, list): - continue - release_data = _as_dict(_as_dict(data.get("releases")).get(release)) - release_pkgs = _as_list(release_data.get("packages")) - release_remove = _as_list(release_data.get("packages_remove")) - # Same logic as parse_desktop's primary_pkg: first DE-specific package - # that survives release_remove. Common packages are excluded. - effective = [p for p in (de_pkgs + release_pkgs) if p not in release_remove] + + de_pkgs = _gather_de_pkgs_at_tier(de_data, "minimal") + release_data = _as_dict(_as_dict(de_data.get("releases")).get(release)) + release_removes = set(_as_list(release_data.get("packages_remove"))) + # release-block additions COULD contribute the primary if the DE has + # an empty minimal tier (no DEs do today, but be tolerant) + for pkg in _as_list(release_data.get("packages")): + if pkg not in de_pkgs: + de_pkgs.append(pkg) + effective = [p for p in de_pkgs if p not in release_removes] if effective: name = fname[:-len(".yaml")] print(f"{name}\t{effective[0]}") @@ -172,15 +357,14 @@ def list_desktops(yaml_dir, release, arch, fmt="tsv"): fpath = os.path.join(yaml_dir, fname) if not os.path.isfile(fpath): continue # skip directories like 'foo.yaml/' that would IsADirectoryError on open() - with open(fpath) as f: - data = yaml.safe_load(f) - if not isinstance(data, dict): + de_data = _load_yaml(fpath) + if not de_data: continue name = fname.replace(".yaml", "") - status = data.get("status", "unsupported") - desc = data.get("description", name) - dm = data.get("display_manager", "lightdm") - releases = _as_dict(data.get("releases")) + status = de_data.get("status", "unsupported") + desc = de_data.get("description", name) + dm = de_data.get("display_manager", "lightdm") + releases = _as_dict(de_data.get("releases")) release_data = _as_dict(releases.get(release)) archs = _as_list(release_data.get("architectures")) supported = arch in archs and release in releases @@ -205,28 +389,33 @@ def list_desktops(yaml_dir, release, arch, fmt="tsv"): print(f"{e['name']}\t{e['status']}\t{'yes' if e['supported'] else 'no'}\t{arch_str}") +def _usage(): + prog = sys.argv[0] + print(f"Usage: {prog} --tier ", file=sys.stderr) + print(f" {prog} --list ", file=sys.stderr) + print(f" {prog} --list-json ", file=sys.stderr) + print(f" {prog} --primaries ", file=sys.stderr) + sys.exit(1) + + if __name__ == "__main__": if len(sys.argv) < 4: - print(f"Usage: {sys.argv[0]} ", file=sys.stderr) - print(f" {sys.argv[0]} --list ", file=sys.stderr) - print(f" {sys.argv[0]} --primaries ", file=sys.stderr) - sys.exit(1) + _usage() yaml_dir = sys.argv[1] if sys.argv[2] in ("--list", "--list-json"): if len(sys.argv) < 5: - print(f"Usage: {sys.argv[0]} {sys.argv[2]} ", file=sys.stderr) - sys.exit(1) + _usage() fmt = "json" if sys.argv[2] == "--list-json" else "tsv" list_desktops(yaml_dir, sys.argv[3], sys.argv[4], fmt=fmt) elif sys.argv[2] == "--primaries": if len(sys.argv) < 5: - print(f"Usage: {sys.argv[0]} --primaries ", file=sys.stderr) - sys.exit(1) + _usage() list_primaries(yaml_dir, sys.argv[3], sys.argv[4]) else: - if len(sys.argv) < 5: - print(f"Usage: {sys.argv[0]} ", file=sys.stderr) - sys.exit(1) - parse_desktop(yaml_dir, sys.argv[2], sys.argv[3], sys.argv[4]) + # parse_desktop: yaml_dir de_name release arch --tier + # The --tier flag is mandatory in the new schema. + if len(sys.argv) != 7 or sys.argv[5] != "--tier": + _usage() + parse_desktop(yaml_dir, sys.argv[2], sys.argv[3], sys.argv[4], sys.argv[6]) diff --git a/tools/modules/desktops/skel/.local/share/applications/gdebi.desktop b/tools/modules/desktops/skel/.local/share/applications/gdebi.desktop deleted file mode 100644 index e1e3e1732..000000000 --- a/tools/modules/desktops/skel/.local/share/applications/gdebi.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/tools/modules/desktops/skel/.local/share/applications/system-config-printer.desktop b/tools/modules/desktops/skel/.local/share/applications/system-config-printer.desktop deleted file mode 100644 index e1e3e1732..000000000 --- a/tools/modules/desktops/skel/.local/share/applications/system-config-printer.desktop +++ /dev/null @@ -1,2 +0,0 @@ -[Desktop Entry] -Hidden=true diff --git a/tools/modules/desktops/yaml/bianbu.yaml b/tools/modules/desktops/yaml/bianbu.yaml index bec5f6063..3e292e0fe 100644 --- a/tools/modules/desktops/yaml/bianbu.yaml +++ b/tools/modules/desktops/yaml/bianbu.yaml @@ -7,22 +7,24 @@ repo: key_url: "https://archive.spacemit.com/bianbu-ports/bianbu-archive-keyring.gpg" keyring: "/usr/share/keyrings/bianbu-archive-keyring.gpg" -packages: - - bianbu-desktop - - bianbu-desktop-en - - bianbu-desktop-zh - - bianbu-desktop-minimal-en - - bianbu-standard - - bianbu-development - - bianbu-esos - - img-gpu-powervr - - k1x-vpu-firmware - - k1x-cam - - spacemit-uart-bt - - spacemit-modules-usrload - - opensbi-spacemit - - u-boot-spacemit - - linux-image-6.1.15 +tiers: + minimal: + packages: + - bianbu-desktop + - bianbu-desktop-en + - bianbu-desktop-zh + - bianbu-desktop-minimal-en + - bianbu-standard + - bianbu-development + - bianbu-esos + - img-gpu-powervr + - k1x-vpu-firmware + - k1x-cam + - spacemit-uart-bt + - spacemit-modules-usrload + - opensbi-spacemit + - u-boot-spacemit + - linux-image-6.1.15 releases: noble: diff --git a/tools/modules/desktops/yaml/budgie.yaml b/tools/modules/desktops/yaml/budgie.yaml index a3ae09c21..c9a4b68e5 100644 --- a/tools/modules/desktops/yaml/budgie.yaml +++ b/tools/modules/desktops/yaml/budgie.yaml @@ -3,37 +3,39 @@ description: "Budgie - elegant desktop from Solus project" display_manager: lightdm status: unsupported -packages: - - budgie-desktop - - budgie-desktop-environment - - lightdm - - slick-greeter - - xserver-xorg - - blueman - - bluez - - bluez-tools - - dbus-x11 - - evince - - gdebi - - gnome-disk-utility - - gnome-screenshot - - gnome-terminal - - gvfs-backends - - lm-sensors - - nemo - - numix-gtk-theme - - numix-icon-theme - - numix-icon-theme-circle - - pavucontrol - - plank - - printer-driver-all - - pulseaudio - - pulseaudio-module-bluetooth - - spice-vdagent - - system-config-printer - - viewnior - - xdg-user-dirs - - xdg-user-dirs-gtk +tiers: + minimal: + packages: + - budgie-desktop + - budgie-desktop-environment + - lightdm + - slick-greeter + - xserver-xorg + - blueman + - bluez + - bluez-tools + - dbus-x11 + - evince + - gdebi + - gnome-disk-utility + - gnome-screenshot + - gnome-terminal + - gvfs-backends + - lm-sensors + - nemo + - numix-gtk-theme + - numix-icon-theme + - numix-icon-theme-circle + - pavucontrol + - plank + - printer-driver-all + - pulseaudio + - pulseaudio-module-bluetooth + - spice-vdagent + - system-config-printer + - viewnior + - xdg-user-dirs + - xdg-user-dirs-gtk releases: bookworm: diff --git a/tools/modules/desktops/yaml/cinnamon.yaml b/tools/modules/desktops/yaml/cinnamon.yaml index 0bce4af7d..cb078e5cf 100644 --- a/tools/modules/desktops/yaml/cinnamon.yaml +++ b/tools/modules/desktops/yaml/cinnamon.yaml @@ -3,50 +3,47 @@ description: "Cinnamon - traditional layout with modern features" display_manager: lightdm status: supported -packages: - - cinnamon - - cinnamon-desktop-environment - - slick-greeter - - blueman - - bluez - - bluez-tools - - network-manager-gnome - - dbus-x11 - - dmz-cursor-theme - - evince - - fonts-ubuntu - - gnome-disk-utility - - gnome-system-monitor - - gtk2-engines - - gtk2-engines-murrine - - gtk2-engines-pixbuf - - gvfs-backends - - libpam-gnome-keyring - - lm-sensors - - numix-gtk-theme - - numix-icon-theme - - numix-icon-theme-circle - - pavucontrol - - printer-driver-all - - pulseaudio - - pulseaudio-module-bluetooth - - spice-vdagent - - system-config-printer - - viewnior - - xdg-user-dirs - - xdg-user-dirs-gtk +tiers: + minimal: + packages: + - cinnamon + - cinnamon-desktop-environment + - slick-greeter + - blueman + - bluez + - bluez-tools + - network-manager-gnome + - dbus-x11 + - dmz-cursor-theme + - evince + - fonts-ubuntu + - gnome-disk-utility + - gnome-system-monitor + - gtk2-engines + - gtk2-engines-murrine + - gtk2-engines-pixbuf + - gvfs-backends + - libpam-gnome-keyring + - lm-sensors + - numix-gtk-theme + - numix-icon-theme + - numix-icon-theme-circle + - pavucontrol + - printer-driver-all + - pulseaudio + - pulseaudio-module-bluetooth + - spice-vdagent + - system-config-printer + - viewnior + - xdg-user-dirs + - xdg-user-dirs-gtk releases: bookworm: architectures: [arm64, amd64] packages: - accountsservice - - gnome-calculator - libu2f-udev - packages_remove: - - libfontembed1 - - update-manager - - update-manager-core trixie: architectures: [arm64, amd64, riscv64] @@ -66,16 +63,8 @@ releases: - polkitd - pkexec - libu2f-udev - packages_remove: - - qalculate-gtk - - hplip - - indicator-printers - - libfontembed1 - - policykit-1 - - printer-driver-all packages_uninstall: - ubuntu-session - - language-selector-gnome plucky: architectures: [arm64, amd64, riscv64] @@ -84,13 +73,6 @@ releases: - pkexec - libu2f-udev packages_remove: - - qalculate-gtk - - hplip - - indicator-printers - - libfontembed1 - - policykit-1 - - printer-driver-all - pavumeter packages_uninstall: - ubuntu-session - - language-selector-gnome diff --git a/tools/modules/desktops/yaml/common.yaml b/tools/modules/desktops/yaml/common.yaml index 7e1e4c3c8..914b8d35f 100644 --- a/tools/modules/desktops/yaml/common.yaml +++ b/tools/modules/desktops/yaml/common.yaml @@ -1,10 +1,180 @@ name: common -description: "Common packages for all desktop environments" - -packages: - - adwaita-icon-theme - - cups - - dconf-cli - - profile-sync-daemon - - terminator - - upower +description: "Packages installed for every desktop, in tiers" + +# Tier model +# ---------- +# Every desktop install is run at one of three tiers: +# +# minimal — DE + display manager + base utilities. No browser, no +# office, no text editor beyond what the DE itself ships. +# Around 500 MB on a fresh install. +# mid — minimal + browser + everyday user-facing tools (text +# editor, calculator, image/PDF viewer, media player, +# archive tool, torrent client). Around 1 GB. +# full — mid + office suite + creative tools (LibreOffice, +# GIMP, Inkscape, Thunderbird, Audacity). Around 2.5 GB. +# +# Tiers are additive: installing 'full' implies 'mid' implies +# 'minimal'. The parser walks them in order and accumulates packages. +# +# Per-DE YAMLs can add packages to a tier with `tiers..packages`, +# or remove ones inherited from common with `tiers..packages_remove`. +# See e.g. kde-plasma.yaml which swaps gnome-text-editor for kate at +# the mid tier and libreoffice-gtk3 for libreoffice-kde at full. +# +# Per-DE per-tier per-arch overrides live under `tier_overrides:` in +# the per-DE YAML. Use those to drop packages that don't exist on a +# particular arch (e.g. blender/inkscape on armhf, libreoffice on +# riscv64). + +tiers: + minimal: + packages: + - adwaita-icon-theme + - cups + - dconf-cli + - profile-sync-daemon + - terminator + - upower + + mid: + packages: + - browser # virtual — resolved per-arch from `browser:` below + - gnome-text-editor # text editor (modern GTK4 successor to gedit) + - gnome-calculator + - loupe # GTK4 image viewer; on bookworm fall back via release block + - vlc # media player + - file-roller # archive manager + - transmission-gtk # torrent client + + full: + packages: + - libreoffice + - libreoffice-gtk3 + - gimp + - inkscape + - thunderbird + - audacity + +# Browser substitution table for the literal `browser` token in any +# tier. The parser replaces `browser` with the right package per +# (release, arch). +# +# Lookup order: +# 1. browser.. — most specific +# 2. browser. — per-arch fallback (when a release +# has no override) +# 3. drop the token entirely (silent — install proceeds without +# a browser rather than failing on a +# literal 'browser' apt name) +# +# The per-release layer is required because the same arch can need +# a different package across releases: +# +# - Debian has 'firefox-esr' but NO 'firefox' package. +# - Ubuntu's 'chromium' .deb is a snap-shim wrapper that pulls in +# snapd. Armbian doesn't ship snapd, so the shim is broken at +# runtime — substitute a real GTK browser instead. +# epiphany-browser (GNOME Web) is small, native deb, and +# available on every Ubuntu arch. +# - 'chromium' isn't built for riscv64 in either Debian or Ubuntu. +# +# Verified against Debian bookworm/trixie and Ubuntu noble/plucky as +# of 2026-04. Update this map when new releases ship or when an +# arch dependency changes. +browser: + bookworm: + amd64: chromium + arm64: chromium + armhf: chromium + # bookworm has no riscv64 port — no entry needed + trixie: + amd64: chromium + arm64: chromium + armhf: chromium + riscv64: firefox-esr # 'firefox' does not exist in Debian + noble: + amd64: epiphany-browser # 'chromium' deb is a snap-shim + arm64: epiphany-browser + armhf: epiphany-browser + riscv64: epiphany-browser # 'chromium'/'firefox' both missing + plucky: + amd64: epiphany-browser + arm64: epiphany-browser + armhf: epiphany-browser + riscv64: epiphany-browser + +# Per-tier holes that exist in the upstream repos and need to be +# stripped before they reach apt. Common.yaml carries the holes that +# apply to every desktop; per-DE YAMLs can add their own. +# +# Layered: +# tier_overrides..architectures..packages_remove +# — apply on this arch in any release +# tier_overrides..releases..architectures..packages_remove +# — apply only on this release+arch combo (preferred for +# per-release transient holes like 'loupe' missing in bookworm) +# +# Verified against packages.debian.org (madison) and +# packages.ubuntu.com / launchpad as of 2026-04. Update when releases +# move forward or when arch dependencies change. +tier_overrides: + minimal: + releases: + bookworm: + # fonts-ubuntu is an Ubuntu-only package; Debian doesn't + # ship it. Strip it on every Debian release. The Ubuntu + # branding font is preserved on Ubuntu installs and the + # rest of the GTK font stack (fonts-noto, fonts-dejavu, + # whatever the DE pulls in transitively) takes over on + # Debian. + architectures: + amd64: { packages_remove: [fonts-ubuntu] } + arm64: { packages_remove: [fonts-ubuntu] } + armhf: { packages_remove: [fonts-ubuntu] } + trixie: + architectures: + amd64: { packages_remove: [fonts-ubuntu] } + arm64: { packages_remove: [fonts-ubuntu] } + armhf: { packages_remove: [fonts-ubuntu] } + riscv64: { packages_remove: [fonts-ubuntu] } + mid: + releases: + bookworm: + # loupe is GNOME 46+; bookworm shipped with GNOME 43 era and + # has no loupe package on any arch. + architectures: + amd64: { packages_remove: [loupe] } + arm64: { packages_remove: [loupe] } + armhf: { packages_remove: [loupe] } + plucky: + # loupe in plucky was dropped on armhf (GNOME 48 didn't ship + # an armhf build for it). Other arches still have it. + architectures: + armhf: { packages_remove: [loupe] } + full: + releases: + bookworm: + # thunderbird is missing on bookworm/armhf. + architectures: + armhf: { packages_remove: [thunderbird] } + trixie: + # thunderbird is missing on trixie/armhf. + architectures: + armhf: { packages_remove: [thunderbird] } + noble: + # thunderbird on Ubuntu is a snap-shim that only exists on + # amd64/arm64; the deb is missing on armhf and riscv64. + # We strip it on EVERY arch on Ubuntu because the shim + # requires snapd which Armbian doesn't ship. + architectures: + amd64: { packages_remove: [thunderbird] } + arm64: { packages_remove: [thunderbird] } + armhf: { packages_remove: [thunderbird] } + riscv64: { packages_remove: [thunderbird] } + plucky: + architectures: + amd64: { packages_remove: [thunderbird] } + arm64: { packages_remove: [thunderbird] } + armhf: { packages_remove: [thunderbird] } + riscv64: { packages_remove: [thunderbird] } diff --git a/tools/modules/desktops/yaml/deepin.yaml b/tools/modules/desktops/yaml/deepin.yaml index 24ef3a2ea..44892220d 100644 --- a/tools/modules/desktops/yaml/deepin.yaml +++ b/tools/modules/desktops/yaml/deepin.yaml @@ -3,36 +3,38 @@ description: "Deepin - DDE desktop environment" display_manager: lightdm status: unsupported -packages: - - dde-control-center - - dde-daemon - - dde-desktop - - dde-dock - - dde-file-manager - - dde-launcher - - dde-polkit-agent - - dde-qt5integration - - dde-session-ui - - deepin-desktop-base - - deepin-desktop-schemas - - deepin-gtk-theme - - deepin-icon-theme - - deepin-sound-theme - - deepin-terminal - - deepin-wm - - startdde - - lightdm - - slick-greeter - - xserver-xorg - - bluez - - dbus-x11 - - gvfs-backends - - lm-sensors - - pavucontrol - - pulseaudio - - pulseaudio-module-bluetooth - - spice-vdagent - - xdg-user-dirs +tiers: + minimal: + packages: + - dde-control-center + - dde-daemon + - dde-desktop + - dde-dock + - dde-file-manager + - dde-launcher + - dde-polkit-agent + - dde-qt5integration + - dde-session-ui + - deepin-desktop-base + - deepin-desktop-schemas + - deepin-gtk-theme + - deepin-icon-theme + - deepin-sound-theme + - deepin-terminal + - deepin-wm + - startdde + - lightdm + - slick-greeter + - xserver-xorg + - bluez + - dbus-x11 + - gvfs-backends + - lm-sensors + - pavucontrol + - pulseaudio + - pulseaudio-module-bluetooth + - spice-vdagent + - xdg-user-dirs releases: bookworm: diff --git a/tools/modules/desktops/yaml/enlightenment.yaml b/tools/modules/desktops/yaml/enlightenment.yaml index 1bcb0728d..fd9cf0c9a 100644 --- a/tools/modules/desktops/yaml/enlightenment.yaml +++ b/tools/modules/desktops/yaml/enlightenment.yaml @@ -3,21 +3,23 @@ description: "Enlightenment - EFL-based desktop" display_manager: lightdm status: supported -packages: - - enlightenment - - terminology - - lightdm - - slick-greeter - - xserver-xorg - - bluez - - dbus-x11 - - gvfs-backends - - lm-sensors - - pavucontrol - - pulseaudio - - pulseaudio-module-bluetooth - - thunar - - xdg-user-dirs +tiers: + minimal: + packages: + - enlightenment + - terminology + - lightdm + - slick-greeter + - xserver-xorg + - bluez + - dbus-x11 + - gvfs-backends + - lm-sensors + - pavucontrol + - pulseaudio + - pulseaudio-module-bluetooth + - thunar + - xdg-user-dirs releases: bookworm: diff --git a/tools/modules/desktops/yaml/gnome.yaml b/tools/modules/desktops/yaml/gnome.yaml index b15d796b4..d2eb9b075 100644 --- a/tools/modules/desktops/yaml/gnome.yaml +++ b/tools/modules/desktops/yaml/gnome.yaml @@ -3,38 +3,67 @@ description: "GNOME - modern, full-featured desktop" display_manager: gdm3 status: supported -packages: - - gnome-session - - gnome-shell - - gnome-control-center - - gnome-system-monitor - - gnome-disk-utility - - gnome-shell-extension-appindicator - - gdm3 - - nautilus - - network-manager-gnome - - dbus-x11 - - gvfs-backends - - lm-sensors - - pulseaudio - - pulseaudio-module-bluetooth - - xdg-user-dirs - - xdg-user-dirs-gtk - - xserver-xorg - - xwayland - - zenity +# Minimal tier: GNOME Shell + control center + the daemons that the +# Quick Settings tiles depend on. Mid and full tier additions are +# mostly inherited from common.yaml — see kde-plasma.yaml for the +# pattern of overriding common-tier entries per DE. The only thing +# GNOME drops from the common mid set is `transmission-gtk`, +# because GNOME ships its own torrent integration via the Files +# / Downloads UI and a stand-alone GTK torrent client is redundant. +tiers: + minimal: + packages: + - gnome-session + - gnome-shell + - gnome-control-center + - gnome-system-monitor + - gnome-disk-utility + - gnome-shell-extension-appindicator + - gdm3 + - nautilus + # Printing: cups is in common.yaml as the spooler, but without + # drivers the GNOME Settings → Printers panel can't add anything. + # printer-driver-all pulls in HPLIP/Gutenprint/Foomatic/Splix. + # gnome-control-center already provides the printer-add wizard, + # so no system-config-printer is needed. + - printer-driver-all + # Bluetooth: bluez is the daemon, gnome-bluetooth-sendto pulls + # libgnome-bluetooth-3.0 which gnome-shell dlopens to render + # the Bluetooth tile in the Quick Settings popover. + - bluez + - gnome-bluetooth-sendto + # Networking: gnome-shell renders the Wi-Fi/Wired tile only when + # the NetworkManager daemon is present. network-manager-gnome + # provides the legacy nm-applet GUI and is unrelated. + - network-manager + - network-manager-gnome + # Power profiles tile in Quick Settings — gnome-control-center + # only Recommends this, so under --no-install-recommends it + # doesn't get pulled in. + - power-profiles-daemon + - dbus-x11 + - gvfs-backends + - lm-sensors + - pulseaudio + - pulseaudio-module-bluetooth + - xdg-user-dirs + - xdg-user-dirs-gtk + - xserver-xorg + - xwayland + - zenity + + mid: + # GNOME has its own download/torrent integration; transmission-gtk + # is redundant on GNOME. + packages_remove: + - transmission-gtk releases: bookworm: architectures: [arm64, amd64] packages: - accountsservice - - gnome-calculator - libu2f-udev - packages_remove: - - libfontembed1 - - update-manager - - update-manager-core trixie: architectures: [arm64, amd64] @@ -54,16 +83,8 @@ releases: - polkitd - pkexec - libu2f-udev - packages_remove: - - qalculate-gtk - - hplip - - indicator-printers - - libfontembed1 - - policykit-1 - - printer-driver-all packages_uninstall: - ubuntu-session - - language-selector-gnome plucky: architectures: [arm64, amd64] @@ -72,13 +93,6 @@ releases: - pkexec - libu2f-udev packages_remove: - - qalculate-gtk - - hplip - - indicator-printers - - libfontembed1 - - policykit-1 - - printer-driver-all - pavumeter packages_uninstall: - ubuntu-session - - language-selector-gnome diff --git a/tools/modules/desktops/yaml/i3-wm.yaml b/tools/modules/desktops/yaml/i3-wm.yaml index 5e0120967..d41e26a4b 100644 --- a/tools/modules/desktops/yaml/i3-wm.yaml +++ b/tools/modules/desktops/yaml/i3-wm.yaml @@ -3,33 +3,35 @@ description: "i3 - lightweight tiling window manager" display_manager: lightdm status: supported -packages: - - i3 - - i3status - - i3lock - - lightdm - - slick-greeter - - xserver-xorg - - xinit - - dbus-x11 - - dmz-cursor-theme - - dunst - - feh - - fonts-ubuntu - - gtk2-engines - - gtk2-engines-murrine - - gtk2-engines-pixbuf - - lm-sensors - - network-manager-gnome - - numix-gtk-theme - - numix-icon-theme - - numix-icon-theme-circle - - pavucontrol - - pulseaudio - - pulseaudio-module-bluetooth - - rofi - - thunar - - xdg-user-dirs +tiers: + minimal: + packages: + - i3 + - i3status + - i3lock + - lightdm + - slick-greeter + - xserver-xorg + - xinit + - dbus-x11 + - dmz-cursor-theme + - dunst + - feh + - fonts-ubuntu + - gtk2-engines + - gtk2-engines-murrine + - gtk2-engines-pixbuf + - lm-sensors + - network-manager-gnome + - numix-gtk-theme + - numix-icon-theme + - numix-icon-theme-circle + - pavucontrol + - pulseaudio + - pulseaudio-module-bluetooth + - rofi + - thunar + - xdg-user-dirs releases: bookworm: @@ -37,8 +39,6 @@ releases: packages: - accountsservice - libu2f-udev - packages_remove: - - libfontembed1 trixie: architectures: [arm64, amd64, armhf, riscv64] @@ -58,8 +58,6 @@ releases: - polkitd - pkexec - libu2f-udev - packages_remove: - - libfontembed1 plucky: architectures: [arm64, amd64, armhf, riscv64] @@ -68,5 +66,4 @@ releases: - pkexec - libu2f-udev packages_remove: - - libfontembed1 - pavumeter diff --git a/tools/modules/desktops/yaml/kde-neon.yaml b/tools/modules/desktops/yaml/kde-neon.yaml index 6d825d89f..178c62f1f 100644 --- a/tools/modules/desktops/yaml/kde-neon.yaml +++ b/tools/modules/desktops/yaml/kde-neon.yaml @@ -7,30 +7,35 @@ repo: key_url: "https://archive.neon.kde.org/public.key" keyring: "/usr/share/keyrings/neon.gpg" -packages: - - neon-desktop - - sddm - - sddm-theme-breeze - - konsole - - dolphin - - bluedevil - - kscreen - - plasma-discover - - plasma-nm - - plasma-pa - - plasma-vault - - pipewire-audio - - pipewire-pulse - - wireplumber - - scdaemon - -packages_uninstall: - - gnome-software - - gnome-keyring - - kdeconnect - - khelpcenter - - thunderbird - - libreoffice* +tiers: + minimal: + packages: + - neon-desktop + - sddm + - sddm-theme-breeze + - konsole + - dolphin + - bluedevil + - kscreen + - plasma-discover + - plasma-nm + - plasma-pa + - plasma-vault + - pipewire-audio + - pipewire-pulse + - wireplumber + - scdaemon + packages_uninstall: + # IMPORTANT: do NOT list anything here that is a Depends of + # neon-desktop. Removing such a package triggers apt's + # autoremove cascade which yanks neon-desktop and a large + # chunk of the plasma stack along with it (same class of bug + # as the xfce4-goodies and language-selector-gnome cascades). + # neon-desktop Depends on kdeconnect and khelpcenter, so + # neither can be listed here. gnome-keyring is also unsafe + # — several KDE/GTK apps pull it via pam dependencies. + - gnome-software + - thunderbird releases: noble: diff --git a/tools/modules/desktops/yaml/kde-plasma.yaml b/tools/modules/desktops/yaml/kde-plasma.yaml index 0987049e3..d4abd39a4 100644 --- a/tools/modules/desktops/yaml/kde-plasma.yaml +++ b/tools/modules/desktops/yaml/kde-plasma.yaml @@ -3,33 +3,64 @@ description: "KDE Plasma - feature-rich customizable desktop" display_manager: sddm status: supported -packages: - - kde-plasma-desktop - - sddm - - sddm-theme-breeze - - konsole - - dolphin - - ark - - bluedevil - - gwenview - - kscreen - - network-manager-gnome - - okular - - plasma-nm - - plasma-pa - - xserver-xorg - - dbus-x11 - - dmz-cursor-theme - - fonts-ubuntu - - gtk2-engines - - gtk2-engines-murrine - - gtk2-engines-pixbuf - - gvfs-backends - - lm-sensors - - pulseaudio - - pulseaudio-module-bluetooth - - spice-vdagent - - xdg-user-dirs +# KDE Plasma minimal already ships its own konsole, dolphin, ark +# (archive manager), gwenview (image viewer), okular (PDF viewer). +# At the mid tier we override common.yaml's GTK choices to use the +# native KDE applications instead, and at full tier we swap +# libreoffice-gtk3 for libreoffice-kde so the office suite uses the +# Plasma integration backend. +tiers: + minimal: + packages: + - kde-plasma-desktop + - sddm + - sddm-theme-breeze + - konsole + - dolphin + - ark + - bluedevil + - gwenview + - kscreen + - network-manager-gnome + - okular + - plasma-nm + - plasma-pa + - xserver-xorg + - dbus-x11 + - dmz-cursor-theme + - fonts-ubuntu + - gtk2-engines + - gtk2-engines-murrine + - gtk2-engines-pixbuf + - gvfs-backends + - lm-sensors + - pulseaudio + - pulseaudio-module-bluetooth + - spice-vdagent + - xdg-user-dirs + + mid: + # Drop the GTK-stack equivalents that KDE already provides via + # ark/gwenview/okular at the minimal tier. + packages_remove: + - gnome-text-editor + - file-roller + - loupe + packages: + - kate + + full: + # KDE Plasma drops the libreoffice-gtk3 integration package + # because we want native KDE file pickers / theming. There used + # to be a 'libreoffice-kde' package but it was retired upstream + # and no longer exists in any Debian / Ubuntu release we support + # (verified against bookworm/trixie/noble/plucky as of 2026). + # Plain 'libreoffice' (inherited from common.yaml full) is fine + # — modern LibreOffice picks up KDE integration automatically + # via libreoffice-style-breeze when it's installed alongside + # Plasma. + packages_remove: + - libreoffice-gtk3 releases: bookworm: @@ -37,8 +68,6 @@ releases: packages: - accountsservice - libu2f-udev - packages_remove: - - libfontembed1 trixie: architectures: [arm64, amd64] @@ -58,8 +87,6 @@ releases: - polkitd - pkexec - libu2f-udev - packages_remove: - - libfontembed1 plucky: architectures: [arm64, amd64] @@ -67,5 +94,3 @@ releases: - polkitd - pkexec - libu2f-udev - packages_remove: - - libfontembed1 diff --git a/tools/modules/desktops/yaml/mate.yaml b/tools/modules/desktops/yaml/mate.yaml index e97ac27a7..c54ae2f2e 100644 --- a/tools/modules/desktops/yaml/mate.yaml +++ b/tools/modules/desktops/yaml/mate.yaml @@ -3,58 +3,54 @@ description: "MATE - traditional GNOME 2 desktop" display_manager: lightdm status: supported -packages: - - mate-desktop-environment - - mate-desktop-environment-extras - - lightdm - - slick-greeter - - xserver-xorg - - blueman - - bluez - - bluez-tools - - network-manager-gnome - - dbus-x11 - - dmz-cursor-theme - - evince - - fonts-ubuntu - - gnome-disk-utility - - gnome-system-monitor - - gtk2-engines - - gtk2-engines-murrine - - gtk2-engines-pixbuf - - gvfs-backends - - libpam-gnome-keyring - - lm-sensors - - numix-gtk-theme - - numix-icon-theme - - numix-icon-theme-circle - - pavucontrol - - printer-driver-all - - pulseaudio - - pulseaudio-module-bluetooth - - spice-vdagent - - system-config-printer - - viewnior - - xdg-user-dirs - - xdg-user-dirs-gtk - -packages_uninstall: - - brisk-menu - - mate-indicator-applet - - mate-indicator-applet-common - - mate-applet-trash +tiers: + minimal: + packages: + - mate-desktop-environment + - mate-desktop-environment-extras + - lightdm + - slick-greeter + - xserver-xorg + - blueman + - bluez + - bluez-tools + - network-manager-gnome + - dbus-x11 + - dmz-cursor-theme + - evince + - fonts-ubuntu + - gnome-disk-utility + - gnome-system-monitor + - gtk2-engines + - gtk2-engines-murrine + - gtk2-engines-pixbuf + - gvfs-backends + - libpam-gnome-keyring + - lm-sensors + - numix-gtk-theme + - numix-icon-theme + - numix-icon-theme-circle + - pavucontrol + - printer-driver-all + - pulseaudio + - pulseaudio-module-bluetooth + - spice-vdagent + - system-config-printer + - viewnior + - xdg-user-dirs + - xdg-user-dirs-gtk + packages_uninstall: + - brisk-menu + - mate-indicator-applet + - mate-indicator-applet-common + - mate-applet-trash releases: bookworm: architectures: [arm64, amd64, armhf] packages: - accountsservice - - gnome-calculator - libu2f-udev - packages_remove: - - libfontembed1 - - update-manager - - update-manager-core trixie: architectures: [arm64, amd64, armhf, riscv64] @@ -74,16 +70,8 @@ releases: - polkitd - pkexec - libu2f-udev - packages_remove: - - qalculate-gtk - - hplip - - indicator-printers - - libfontembed1 - - policykit-1 - - printer-driver-all packages_uninstall: - ubuntu-session - - language-selector-gnome plucky: architectures: [arm64, amd64, armhf, riscv64] @@ -92,13 +80,6 @@ releases: - pkexec - libu2f-udev packages_remove: - - qalculate-gtk - - hplip - - indicator-printers - - libfontembed1 - - policykit-1 - - printer-driver-all - pavumeter packages_uninstall: - ubuntu-session - - language-selector-gnome diff --git a/tools/modules/desktops/yaml/xfce.yaml b/tools/modules/desktops/yaml/xfce.yaml index c1255d5d7..6ee17451f 100644 --- a/tools/modules/desktops/yaml/xfce.yaml +++ b/tools/modules/desktops/yaml/xfce.yaml @@ -3,66 +3,64 @@ description: "XFCE - lightweight and fast desktop" display_manager: lightdm status: supported -packages: - - xfce4 - - xfce4-goodies - - xfce4-power-manager - - lightdm - - slick-greeter - - xserver-xorg - - blueman - - bluez - - bluez-tools - - dbus-x11 - - dmz-cursor-theme - - evince - - fonts-ubuntu - - gdebi - - gnome-disk-utility - - gnome-system-monitor - - gtk2-engines - - gtk2-engines-murrine - - gtk2-engines-pixbuf - - gvfs-backends - - libpam-gnome-keyring - - lm-sensors - - mousepad - - numix-gtk-theme - - numix-icon-theme - - numix-icon-theme-circle - - pavucontrol - - pulseaudio - - pulseaudio-module-bluetooth - - printer-driver-all - - spice-vdagent - - system-config-printer - - thunar-volman - - viewnior - - xdg-user-dirs - - xdg-user-dirs-gtk - -packages_uninstall: - - ristretto - - xfburn - - xfce4-clipman - - xfce4-clipman-plugin - - xfce4-dict - - xfce4-notes - - xfce4-notes-plugin - - xfce4-terminal - - xsensors +# Minimal tier: the DE itself + display manager + base utilities. +# Mid and full tier additions live in common.yaml; xfce inherits them +# all without override. See common.yaml `tiers.mid` / `tiers.full`. +tiers: + minimal: + packages: + - xfce4 + - xfce4-goodies + - xfce4-power-manager + - lightdm + - slick-greeter + - xserver-xorg + - blueman + - bluez + - bluez-tools + - dbus-x11 + - dmz-cursor-theme + - evince + - fonts-ubuntu + - gnome-disk-utility + - gtk2-engines + - gtk2-engines-murrine + - gtk2-engines-pixbuf + - gvfs-backends + - libpam-gnome-keyring + - lm-sensors + - mousepad + - numix-gtk-theme + - numix-icon-theme + - numix-icon-theme-circle + - pavucontrol + - pulseaudio + - pulseaudio-module-bluetooth + - printer-driver-all + - system-config-printer + - thunar-volman + - viewnior + - xdg-user-dirs + - xdg-user-dirs-gtk + packages_uninstall: + # IMPORTANT: do NOT list any package here that is a Depends of + # xfce4-goodies. apt removal of such a package yanks xfce4-goodies + # itself, and on systems with apt's autoremove behavior enabled + # the cascade then takes out half the desktop (xserver-xorg, + # pulseaudio, cups, evince, mousepad, ...). Keep this list strictly + # to packages that are *orthogonal* to the xfce dep tree. + # Ubuntu crash reporter — Armbian doesn't consume apport reports. + - apport + - python3-apport + - python3-problem-report + - libsnapd-glib-2-1 releases: bookworm: architectures: [arm64, amd64, armhf] packages: - accountsservice - - gnome-calculator - libu2f-udev - packages_remove: - - libfontembed1 - - update-manager - - update-manager-core trixie: architectures: [arm64, amd64, armhf, riscv64] @@ -82,16 +80,12 @@ releases: - polkitd - pkexec - libu2f-udev - packages_remove: - - qalculate-gtk - - hplip - - indicator-printers - - libfontembed1 - - policykit-1 - - printer-driver-all packages_uninstall: + # Removing ubuntu-session strips the Ubuntu desktop branding + # session from gdm. Do NOT add language-selector-gnome here: + # gnome-control-center has a hard dependency on it, so apt + # would yank gnome-control-center along with it. - ubuntu-session - - language-selector-gnome plucky: architectures: [arm64, amd64, armhf, riscv64] @@ -100,13 +94,7 @@ releases: - pkexec - libu2f-udev packages_remove: - - qalculate-gtk - - hplip - - indicator-printers - - libfontembed1 - - policykit-1 - - printer-driver-all + # pavumeter was dropped from the Ubuntu plucky archive - pavumeter packages_uninstall: - ubuntu-session - - language-selector-gnome diff --git a/tools/modules/desktops/yaml/xmonad.yaml b/tools/modules/desktops/yaml/xmonad.yaml index 01f4c2528..32bd95166 100644 --- a/tools/modules/desktops/yaml/xmonad.yaml +++ b/tools/modules/desktops/yaml/xmonad.yaml @@ -3,24 +3,26 @@ description: "Xmonad - Haskell tiling window manager" display_manager: lightdm status: supported -packages: - - xmonad - - xmobar - - lightdm - - slick-greeter - - xserver-xorg - - xterm - - dbus-x11 - - dmenu - - dunst - - feh - - lm-sensors - - network-manager-gnome - - pavucontrol - - pulseaudio - - pulseaudio-module-bluetooth - - thunar - - xdg-user-dirs +tiers: + minimal: + packages: + - xmonad + - xmobar + - lightdm + - slick-greeter + - xserver-xorg + - xterm + - dbus-x11 + - dmenu + - dunst + - feh + - lm-sensors + - network-manager-gnome + - pavucontrol + - pulseaudio + - pulseaudio-module-bluetooth + - thunar + - xdg-user-dirs releases: bookworm: diff --git a/tools/modules/functions/module_dialog_ui.sh b/tools/modules/functions/module_dialog_ui.sh index d0eece9ba..898c237d0 100644 --- a/tools/modules/functions/module_dialog_ui.sh +++ b/tools/modules/functions/module_dialog_ui.sh @@ -664,12 +664,16 @@ dialog_menu() { ((i++)) done elif $use_item_help; then - # Triplets of tag, item, and help text + # Triplets of tag, item, and help text. Only print tag + + # item; the help text is meant for F1/hover in dialog and + # would just produce noisy three-segment lines like + # "1. CINM01 - Install Cinnamon - Install the Cinnamon desktop environment" + # in this read-mode fallback. local i=1 for ((j=0; j<${#options[@]}; j+=3)); do # Remove " - " prefix from description for cleaner display local desc="${options[j+1]#\ -\ }" - echo "$i. ${options[j]} - $desc - ${options[j+2]}" >&2 + echo "$i. ${options[j]} - $desc" >&2 ((i++)) done else diff --git a/tools/modules/functions/module_env_init.sh b/tools/modules/functions/module_env_init.sh index 18b6389e5..abebd1207 100644 --- a/tools/modules/functions/module_env_init.sh +++ b/tools/modules/functions/module_env_init.sh @@ -136,14 +136,20 @@ function set_runtime_variables() { BACKTITLE="\Zb\Z7Support Armbian:\Zn https://github.com/sponsors/armbian" TITLE="armbian-config" [[ -z "${DEFAULT_ADAPTER// /}" ]] && DEFAULT_ADAPTER="lo" - # zfs subsystem - determine if our kernel is not too recent - ZFS_DKMS_VERSION=$(LC_ALL=C apt-cache policy zfs-dkms | grep Candidate | xargs | cut -d" " -f2 | cut -c-5) - ZFS_KERNEL_MAX=$(wget -qO- https://raw.githubusercontent.com/openzfs/zfs/refs/tags/zfs-${ZFS_DKMS_VERSION}/META | grep Maximum | cut -d" " -f2) - # sometimes Ubuntu sets higher version then existing tag. Lets probe previous version - if [[ -z "${ZFS_KERNEL_MAX}" ]]; then - local previous_version="$(printf "%03d" "$(expr "$(echo $ZFS_DKMS_VERSION | sed 's/\.//g')" - 1)")" - local previous_version=$(echo "${previous_version:0:1}.${previous_version:1:1}.${previous_version:2:1}") - ZFS_KERNEL_MAX=$(wget -qO- https://raw.githubusercontent.com/openzfs/zfs/refs/tags/zfs-${previous_version}/META | grep Maximum | cut -d" " -f2) + # zfs subsystem - determine if our kernel is not too recent. + # In test containers / minimal images zfs-dkms may not be in any + # enabled apt repo, in which case ZFS_DKMS_VERSION ends up empty + # and the previous-version probe below trips 'expr: non-integer + # argument'. Skip the whole probe when the candidate is empty. + ZFS_DKMS_VERSION=$(LC_ALL=C apt-cache policy zfs-dkms 2>/dev/null | grep Candidate | xargs | cut -d" " -f2 | cut -c-5) + if [[ -n "${ZFS_DKMS_VERSION}" && "${ZFS_DKMS_VERSION}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + ZFS_KERNEL_MAX=$(wget -qO- https://raw.githubusercontent.com/openzfs/zfs/refs/tags/zfs-${ZFS_DKMS_VERSION}/META | grep Maximum | cut -d" " -f2) + # sometimes Ubuntu sets higher version then existing tag. Lets probe previous version + if [[ -z "${ZFS_KERNEL_MAX}" ]]; then + local previous_version="$(printf "%03d" "$(expr "$(echo $ZFS_DKMS_VERSION | sed 's/\.//g')" - 1)")" + local previous_version=$(echo "${previous_version:0:1}.${previous_version:1:1}.${previous_version:2:1}") + ZFS_KERNEL_MAX=$(wget -qO- https://raw.githubusercontent.com/openzfs/zfs/refs/tags/zfs-${previous_version}/META | grep Maximum | cut -d" " -f2) + fi fi # detect desktop check_desktop diff --git a/tools/modules/functions/module_package.sh b/tools/modules/functions/module_package.sh index 9316f33af..47b0a9b8a 100644 --- a/tools/modules/functions/module_package.sh +++ b/tools/modules/functions/module_package.sh @@ -13,7 +13,7 @@ module_options+=( # Wrapper for apt operations with progress display # Replaces debconf-apt-progress with dialog_gauge UI # Usage: apt_operation_progress [apt_args...] -# Operations: update, upgrade, full-upgrade, install, remove, fix-broken +# Operations: update, upgrade, full-upgrade, install, remove, fix-broken, clean apt_operation_progress() { local operation="$1" shift @@ -41,6 +41,9 @@ apt_operation_progress() { fix-broken) title="Fix Broken Packages" ;; + clean) + title="Clean Package Cache" + ;; *) title="APT Operation" ;; @@ -221,6 +224,19 @@ pkg_installed() ! [[ -z "$status" || "$status" = *deinstall* || "$status" = *not-installed* ]] } +module_options+=( + ["pkg_clean,author"]="@igorpecovnik" + ["pkg_clean,desc"]="Clear apt's downloaded .deb cache (apt-get clean)" + ["pkg_clean,example"]="pkg_clean" + ["pkg_clean,feature"]="pkg_clean" + ["pkg_clean,status"]="Interface" +) + +pkg_clean() +{ + apt_operation_progress clean "$@" +} + module_options+=( ["pkg_remove,author"]="@dimitry-ishenko" ["pkg_remove,desc"]="Remove package"