the great unbuntening: ui goes brrr + full review in REVIEW.md

- splash no longer does the 5.5s slow-scroll flex
- apt-get update only runs if lists are >6h old (-r/--refresh to force)
- log() fork-free, debug spam gated behind ULTRABUNT_DEBUG=1
- category menu counts in one pass, no more dpkg fork storm per redraw
- deleted refresh_cache_silent (600 background forks doing literally nothing)
- rc-state packages no longer show as installed; flatpak exact-match
- bulk ops no longer die after first package (((x++)) set -e trap)
- bulk checklists no longer scramble themselves when sorting
- ESC no longer nukes the whole script in buntage/log-viewer menus
- swappiness menu: fixed regex crash + duplicate sysctl lines
- wp hardening: domain validated (no more chown -R /var via ".."), options actually apply now
- fun category (15 buntages) rescued from the void
- --no-media/--no-web/--no-network/-minimal/-core now exclude real categories

REVIEW.md has the full findings list incl. everything not fixed yet

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jing 2026-07-17 11:37:48 +10:00
parent 63a51e7e0a
commit 529039dc87
2 changed files with 337 additions and 248 deletions

124
REVIEW.md Normal file
View File

@ -0,0 +1,124 @@
# Ultrabunt Code Review — 2026-07-17
Full review of `ultrabunt.sh` (17k lines) and `ultrabunt-accessible.sh`.
Scope: real breakage and danger only — style ignored, silliness preserved.
Line numbers are approximate (the performance pass shifted things by a few
dozen lines in places) — search for the quoted function name if a number is off.
---
## Fixed in this pass (performance + crashers)
These are already applied to `ultrabunt.sh`:
| What | Why it was slow/broken |
|---|---|
| Splash screen printed instantly | 78 lines × `sleep 0.05` + `sleep 1.5` = ~5.5s of dead air at every launch |
| `apt-get update` skipped when lists < 6h old (`--refresh` to force) | Ran unconditionally at startup: 1060s per launch on old hardware |
| `log()` rewritten fork-free (`printf %(...)T` + direct append) | Old version forked ~3 processes per line (`$(date)`, pipe, `tee`) and was called per package per menu redraw; also sprayed stderr underneath whiptail. Set `ULTRABUNT_DEBUG=1` to mirror logs to stderr |
| Removed per-check logging from `is_package_installed` | 3+ log lines (≈9 forks) per package, per menu redraw |
| Category counts computed in ONE pass over `PACKAGES` | Old loop re-walked all ~600 packages once per category (~15,000 iterations) on every main-menu redraw |
| `is_apt_installed` cache miss is now authoritative after startup scan | Old fallback forked `dpkg -l` for every NOT-installed package on every redraw — hundreds of forks per screen |
| `build_package_cache` always does one full dump per package manager | "Selective mode" forked one `dpkg-query` per package — strictly slower than dumping everything (which is 1 fork) |
| Deleted `refresh_cache_silent` + the 🔄 indicator | It forked ~600 background processes whose cache writes happened in a **child shell and were thrown away** — a subshell can't modify the parent's `INSTALLED_CACHE`. 100% wasted CPU on a machine that has none to spare |
| Removed `log "Menu items array contents: ..."` | Wrote the entire ~5KB menu array to the log on every redraw |
| dpkg checks filter on `Status-Status == installed` | `dpkg-query -W` also lists removed-but-not-purged (`rc`) packages, so uninstalled packages showed ✓ Installed |
| Flatpak checks use exact match (`grep -Fxq` on the app-id column) | Bare `grep -q "$pkg"` matched substrings |
| `((installed++))`-style counters → `x=$((x+1))` (6 sites in bulk ops) | `((x++))` returns exit 1 on the 0→1 increment; under `set -e` every bulk install/remove **killed the whole script after the first package** |
| Bulk-select checklists sort names first, then build triples | Old code joined tag/desc/OFF triples into lines and sorted ALL lines, scrambling the checklist alignment |
| `show_buntage_actions` menu guarded with `\|\| return`; install/reinstall success message now conditional on actual success | Cancel killed the script via `set -e`; failures reported "installed successfully!" |
| `view_log_file` menu guarded with `\|\| return` | ESC/Cancel in the log viewer killed the whole script |
| Swappiness regexes fixed (`vm\\.swappiness` → `vm\.swappiness`) + no-match grep guarded | The `\\.` inside single quotes is a literal backslash: "show entry" **crashed the script** and a duplicate `vm.swappiness` line was appended to sysctl.conf on every persist |
| `wordpress_security_hardening`: domain validated, wp-config.php required | Entering `..` made `site_dir=/var/www/..` = `/var`, then "Fix File Permissions" ran `chown -R www-data:www-data /var`**system-wrecking** |
| Hardening checklist strips whiptail's literal quotes | No case arm ever matched `"file-permissions"` (with quotes), so every hardening option was silently skipped while reporting success |
| `fun` category added to `CATEGORIES` | 15 packages were tagged `fun` but the category wasn't in the menu — permanently invisible |
| `--no-media/--no-web/--no-network/-minimal/-core` use real category ids | They referenced ids that don't exist (`multimedia`, `music`, `web-browsers`, `network`, `education`…), so the exclusions silently did nothing |
| Package-list sort uses `mapfile` instead of unquoted `$(sort)` | Glob chars in descriptions (`(_*_)`) could expand against files in the cwd |
Expected result: startup goes from ~4070s to a few seconds (splash + one dpkg/snap/flatpak dump), and menu redraws go from seconds of fork-storm to near-instant pure-bash.
---
## Outstanding — HIGH (dangerous or feature completely broken)
### WordPress / database
- **`finalize_wordpress_setup`**: custom setup's user-entered `site_dir` is ignored by the actual installers (they all install to `/var/www/$domain`) but the *finalizer still runs* `chown -R $(whoami):www-data "$site_dir"` + recursive chmod on the unvalidated path. Enter `/home` and it re-owns it. Validate/derive `site_dir` the same way the installers do.
- **SQL injection everywhere a password/name is interpolated into `mysql -e "... IDENTIFIED BY '$pass'"`** (`setup_wordpress_database*`, `create_database_user_interactive` ~L16381, DB repair `eval` ~L13870). A single `'` in a password breaks or injects as MariaDB root. The repair path pipes it through `eval` too. Fix: validate names as `[A-Za-z0-9_]+`, pass passwords via `--defaults-extra-file` or here-doc `SET PASSWORD` with proper quoting.
- **`setup_wordpress_database_custom` runs under command substitution but its whiptail dialogs aren't redirected to `/dev/tty`** — dialogs are captured into the result variable instead of being shown; flow appears hung and wp-config gets garbage credentials.
- **wp-config salts**: the awk insert anchors on `/** MySQL settings` which WordPress renamed in 5.8 — the sed first *deletes* all AUTH_KEY…NONCE_SALT lines, then the insert never fires → **modern WP sites end up with no security keys at all**, silently.
- **`setup_nginx_ssl`** writes the 443 block to `sites-available/$domain` while `.localhost` sites were created under the stripped base name → new file is never enabled; reports success, HTTPS never serves. Also appends a duplicate 443 block on every rerun (`tee -a`).
- **phpMyAdmin installer** writes the MariaDB root password to predictable `/tmp/mysql_defaults_phpmyadmin.cnf` (pre-creatable by any local user; 644 window before chmod), and the follow-up `mysql --defaults-file` runs as the wrong user so it silently no-ops. Root password also logged in plaintext to `/var/log/ultrabunt.log` (~L10883) and wp-config gets loosened to 0664 (group-writable by www-data, world-readable).
- **`delete_wordpress_site`** reads `/root/.mysql_root_password` *without sudo* → DB/user never actually dropped while reporting "completely removed including database"; also deletes the nginx vhost matching the *first label* of the domain (deleting `shop.example.com` can nuke `shop.localhost`'s config), and will DROP a database shared by other sites without cross-checking.
- **`grant_file_access_to_user`**: `chmod -R 2775 /var/www` makes every *file* group-writable+setgid and world-readable — www-data can rewrite all PHP (persistence vector) and wp-config passwords are world-readable. Use 664 files / 2775 dirs.
- **`show_individual_site_management`** uses `$site_dir` (never set locally — dynamically inherits the caller's *last* loop value) instead of the `site_root` it computes → wp-config view/edit/DB-info act on the **wrong site** whenever >1 site exists.
### Cloudflare / firewall
- **`cloudflare_create_rule` builds its JSON with raw printf and no escaping** — every filter expression contains quotes (some contain newlines) → malformed JSON → **every rule creation fails**, including "light". The whole feature can't work as written. Also uses the deprecated legacy `firewall/rules` endpoint, and the country code `UK` should be `GB` (would block UK visitors incl. you).
### Mirror management
- **The entire "fastest mirror" feature is dead**: it invokes `mirrorselect` (a Gentoo tool) which is never installed (`install_mirrorselect` actually installs `netselect-apt`). The auto mode uses netselect-apt's **Debian** mirror list and seds those hosts over `archive.ubuntu.com` → apt breaks. The "restore" path recomputes `$(date +%s)`-style backup names so it restores from a filename that doesn't exist and dies under `set -e` with sources.list already clobbered. `use_local_mirror`/`setup_local_mirror` write to `/etc/apt/` **without sudo** → instant permission-denied crash. On 24.04+ the real sources live in `/etc/apt/sources.list.d/ubuntu.sources` anyway. Recommend: rip this section out or rewrite around `netselect-apt`-for-Ubuntu with sudo and proper backup naming.
- `run_mirrorselect_custom` does `eval "sudo mirrorselect $custom_flags"` on free-typed input — arbitrary shell as root by design.
### Reverse-proxy add-ons
- **Umami**: publishes `3000:3000` on 0.0.0.0 (Docker's iptables bypass UFW) with hardcoded `APP_SECRET: replace-me-with-a-random-string` and default admin/umami login → internet-reachable with forgeable sessions on any VPS.
- **Mailcow**: mailcow's own nginx defaults to 80/443 (collides with host nginx) while the proxy snippet assumes `127.0.0.1:8080` which nothing configures — either compose fails or the proxy points at a dead port; completion message advertises `https://` URLs no config provides.
- Both add-on flows have zero error handling: any failed step under `set -e` kills the whole script with containers half-started and nginx confs already symlinked.
### Installers (selection — full list in agent notes)
- **`install_arduino-ide`**: `unzip --strip-components=1` — flag doesn't exist; wipes `/opt/arduino-ide` then always fails, and `set -e` kills the script.
- **`install_mailhog`**: `curl -Lo` without `-f` → 404 page installed as a root systemd-managed binary; also downloads into whatever cwd the script happens to be in (deno/zed installers `cd` into temp dirs and delete them, leaving a dead cwd).
- **Plex**: `curl | sudo apt-key add -``apt-key` is gone on newer Ubuntu, so it aborts *after* deleting the old sources; key would land in the global trusted keyring.
- **Fixed predictable `/tmp` paths + `sudo mv`/`dpkg -i`** (lazydocker, glow, cheat, broot, dog, discord, ums, gollama, koboldcpp, LM Studio…): a local user can pre-plant/race the file and get a root-installed binary. Use `mktemp -d`.
- **`dpkg -i || apt-get install -f -y`** pattern (6+ sites): non-dependency dpkg failures are masked and reported as success.
- **whisper.cpp** builds unpinned master and copies `main`/`quantize` — upstream renamed the binaries; always fails now, `make 2>/dev/null` hides why.
- Version-pinned URLs that 404 now or soon: ctop (latest/download + 0.7.7), kind v0.20.0, Insomnia 8.6.1, mergerfs jammy-only deb, LM Studio 0.3.5, UMS deb name, balena-etcher remove targets the old package name.
- `curl | bash` as root (nodesource, ollama, starship, ddev — ddev also points at the moved `drud/ddev` repo) — accepted risk for a personal tool, but at least pin/checksum the big ones.
- `gpg --dearmor` without `--yes` (caddy, ngrok, docker, warp): reruns abort mid-way after old keys were already deleted.
### Packages that can never install (verified against live registries)
- **apt, wrong/absent name**: `sqlitestudio` (26.04+ only), `chromium` (→ `chromium-browser`), `asciiquarium`, `batcat` (→ `bat`, and duplicates the existing `bat` entry), `mysql-workbench-community` (needs MySQL repo), `pgadmin4` (needs pgadmin repo), `bottom`, `google-drive-ocamlfuse` (PPA-only), `jellyfin` (needs jellyfin repo), `citra`, `dosbox-staging`, `python3-esptool` (→ `esptool`), `webcamize`; version-dependent: `exa` (gone in 24.04+, keep `eza`), `pastel`, `fastfetch` (25.04+), `qalculate` (→ `qalculate-gtk`), `bsnes`, `git-delta` (23.10+).
- **snap, 404**: `tableplus`, `mongodb-compass`, `nextcloud-server` (→ `nextcloud`), `heroic`, `dosbox`, `platformio`.
- **cargo/npm**: `qmasa` (→ `qmassa`), `dust` (→ `du-dust`), `adminmongo` (not on npm).
- **flatpak**: citra + yuzu delisted from Flathub (Nintendo takedown); `freetube`/`stremio` use bare names instead of app IDs.
---
## Outstanding — MEDIUM
- MariaDB/MySQL passwords passed as `-p"$pass"` on the command line **everywhere** (~20 sites) — visible in `/proc/*/cmdline` to any local user (matters most on the VPS). Standard fix: `--defaults-extra-file=<(...)` or `MYSQL_PWD` for a personal box.
- DB dumps from `backup_database_interactive` written world-readable (default umask) into `$BACKUP_DIR`; `grep -q "^${database}$"` treats the name as a regex.
- wp-config credential extraction via `grep | cut -d"'" -f4` matches commented lines, no `head -1`, breaks on `'` in passwords (DB test/repair flows).
- `restart_web_services` probes only php 8.1/8.0/7.4-fpm — never restarts the script's own PHP 8.3 FPM, so "optimize uploads" silently doesn't land. Same class: SQL-import optimizer derives paths from CLI `php -v` (can be a different version than FPM); `client_max_body_size` sed lowers larger values across *all* vhosts; `<IfModule mod_php8.c>` never matches; `mysql.connect_timeout` should be `mysqli.connect_timeout`.
- MySQL "import optimization" permanently sets `innodb_flush_log_at_trx_commit=2` and blind `buffer_pool=512M` with no revert or RAM check; PHP `memory_limit=8G` per worker can OOM a small machine.
- `modify_php_setting` interpolates unvalidated input into sed (a `/`, `&` or `\` corrupts php.ini).
- Bulk ops: `install_package ... | tee` runs in a pipeline subshell, so cache updates inside are discarded — status can go stale for the session (harmless-ish now that redraws are cheap; re-run `build_package_cache` after bulk ops to be exact).
- `ui_menu` returns the literal string `"back"` on ESC — any future package named `back` breaks navigation; bulk category picker can pass `"back"` into the match loop.
- Web-config-summary site pickers push 3 args per non-live site into a *menu* (that's checklist syntax) — misaligned rows; "No live sites" branch unreachable.
- Log viewer: Ctrl+C on `tail -f` SIGINTs the whole script (no trap); pager temp file at predictable `/tmp/ultrabunt_log_view_$$` copies sudo-read logs world-readable.
- `wordpress_cleanup_menu` find: `-name "*.localhost" -o -name "*.local" -print0``-print0` binds to one branch; `.localhost` sites never listed.
- certbot always adds `-d www.$domain` — fails for domains without a www record.
- Hotkeys: >24 categories get lowercase tags but the handler uppercases before lookup (opens the wrong category); >48 reuses letters and silently overwrites. The "cycling" code for shared hotkeys is dead (assignment overwrites, never appends). Currently 27 categories, so the first overflow row (`a`) is already live.
- `ultrabunt-accessible.sh`: launches `./ultrabunt.sh` relative to caller's cwd; instructs running the whole thing via `sudo`, which breaks `$USER`-based ownership logic in the main script and makes TTS silent (root has no Pulse session); `timeout 10s echo | festival` applies the timeout to `echo` (festival can hang forever); `sudo -u user cat > file` redirection still runs as root.
## Outstanding — LOW (grab-bag)
- `show_system_info` hardcodes a 50×140 dialog — breaks on a stock 80×24 SSH window.
- Export list writes to predictable `/tmp/ultrabunt-packages-<ts>.txt`.
- `get_status()` is dead code (never called; no arms for custom/npm/cargo methods).
- `silversearcher-ag` and `ag` are duplicate entries for the same package.
- `remove_phpmyadmin` only restarts services that are `is-enabled` — a running-but-disabled DB/web server stays down. `--force-yes` no longer exists in modern apt.
- `__site_is_live` needs `host` (not on minimal Ubuntu) → everything reports "not live".
- Keyboard config writes to `$HOME`/gsettings — configures root's session if run under sudo.
- bun installer greps live `$PATH` instead of rc files → duplicate export lines per rerun.
- README drift: claims pip is removed but `install_pip_package`/npm/cargo helpers and CUSTOM npm tools are still in the script; claims 25+ categories/560+ apps — counts don't match the code.
---
## Suggested priorities
1. **WordPress DB quoting + the `finalize_wordpress_setup` chown** — it's the feature you actually use, on machines you keep.
2. **Delete or rewrite mirror management** — it cannot work and it edits sources.list.
3. **Umami/Mailcow port + secret fixes** if you ever run them on the VPS.
4. **Purge the ~25 never-installable package entries** (list above) — they just waste menu space and end in failure dialogs.
5. Sweep the installers for `curl -f`, `mktemp -d`, and `gpg --dearmor --yes` — three mechanical patterns fix most of the installer findings.

View File

@ -44,8 +44,14 @@ DIALOG_PRESETS[huge]="40 140 25"
# Current dialog preset
CURRENT_DIALOG_PRESET="standard"
# Set by -r/--refresh to force apt-get update at startup even if lists are fresh
FORCE_APT_UPDATE=0
# Cache for installed buntages - populated once at startup
declare -A INSTALLED_CACHE
# Set to 1 once build_package_cache has done its full scan; after that a
# cache miss is authoritative (= not installed) and no dpkg fallback is needed
CACHE_PRIMED=0
# Session variables for database authentication
@ -257,11 +263,8 @@ show_ascii_splash() {
"░░█████████ ░░░███████░ █████ █████ "
)
# Display each line with scrolling effect
for line in "${ascii_lines[@]}"; do
echo "$line"
sleep 0.05 # Small delay for scrolling effect
done
# Display the art in one write - the old per-line scroll effect cost ~4s
printf '%s\n' "${ascii_lines[@]}"
echo -e "${NC}"
@ -274,7 +277,7 @@ show_ascii_splash() {
echo ""
# Brief pause before continuing
sleep 1.5
sleep 0.5
}
# Parse command line arguments
@ -303,12 +306,15 @@ parse_arguments() {
log "Excluding AI category"
;;
-media|--no-media)
EXCLUDED_CATEGORIES["multimedia"]=1
EXCLUDED_CATEGORIES["music"]=1
# Real category ids are audio/video/media-servers (the old
# multimedia/music ids never existed, so this flag was a no-op)
EXCLUDED_CATEGORIES["audio"]=1
EXCLUDED_CATEGORIES["video"]=1
EXCLUDED_CATEGORIES["media-servers"]=1
log "Excluding media categories"
;;
-web|--no-web)
EXCLUDED_CATEGORIES["web-browsers"]=1
EXCLUDED_CATEGORIES["browsers"]=1
log "Excluding web browsers category"
;;
-comm|--no-communication)
@ -328,7 +334,7 @@ parse_arguments() {
log "Excluding security category"
;;
-network|--no-network)
EXCLUDED_CATEGORIES["network"]=1
EXCLUDED_CATEGORIES["network-monitoring"]=1
log "Excluding network category"
;;
-system|--no-system)
@ -356,15 +362,21 @@ parse_arguments() {
log "Excluding cloud category"
;;
-minimal|--minimal)
# Exclude most categories, keep only core and system
for cat in dev wordpress gaming-platforms gaming-emulators ai multimedia music web-browsers communication office graphics security network education science finance virtualization cloud; do
# Exclude most categories, keep core / system / utilities / web
# (uses the real category ids - the old list was full of ids
# that never existed, so minimal mode barely excluded anything)
for cat in dev ai containers shell editors browsers system-monitoring network-monitoring performance-tools database security office communication graphics audio video media-servers cloud terminals gaming-platforms gaming-emulators sbc fun; do
EXCLUDED_CATEGORIES["$cat"]=1
done
log "Minimal mode: excluding most categories"
;;
-r|--refresh)
FORCE_APT_UPDATE=1
log "Forcing APT cache refresh at startup"
;;
-core|--core-only)
# Exclude everything except core
for cat in dev wordpress gaming-platforms gaming-emulators ai multimedia music web-browsers communication office graphics security network system education science finance virtualization cloud; do
for cat in dev ai containers web shell editors browsers system-monitoring network-monitoring performance-tools utilities database security system office communication graphics audio video media-servers cloud terminals gaming-platforms gaming-emulators sbc fun; do
EXCLUDED_CATEGORIES["$cat"]=1
done
log "Core-only mode: excluding all except core category"
@ -390,6 +402,8 @@ USAGE:
OPTIONS:
-h, --help Show this help message
-r, --refresh Force 'apt-get update' at startup (otherwise
skipped when lists are less than 6 hours old)
SELECTIVE LOADING (exclude categories to speed up startup):
-dev, --no-dev Exclude development tools
@ -444,110 +458,43 @@ init_logging() {
sudo chown "$USER:$USER" "$LOGFILE" "$BACKUP_DIR" 2>/dev/null || true
}
# Build cache of all installed buntages for fast lookups
# Build cache of all installed buntages for fast lookups.
# Always does one full dump per package manager - a single dpkg-query/snap/
# flatpak fork each. (The old "selective mode" forked one query per package,
# which was strictly slower than dumping everything.)
# Once this has run, CACHE_PRIMED=1 and a cache miss means "not installed" -
# no per-package fallback forks needed.
build_package_cache() {
log "Building buntage installation cache..."
# Clear existing cache
INSTALLED_CACHE=()
# If selective loading is enabled, only cache packages from non-excluded categories
local selective_mode=false
if [[ ${#EXCLUDED_CATEGORIES[@]} -gt 0 ]]; then
selective_mode=true
log "Selective loading enabled - optimizing cache for included categories only"
fi
# Cache APT buntages
# Cache APT buntages. Filter on Status-Status: a bare dpkg-query -W also
# lists removed-but-not-purged (rc) packages, which showed as installed.
log "Caching APT buntages..."
if [[ "$selective_mode" == "true" ]]; then
# Only cache packages from included categories
for name in "${!PACKAGES[@]}"; do
local pkg_category="${PKG_CATEGORY[$name]:-}"
local method="${PKG_METHOD[$name]:-}"
# Skip if category is excluded
if [[ -n "${EXCLUDED_CATEGORIES[$pkg_category]:-}" ]]; then
continue
fi
# Only check APT packages in this section
if [[ "$method" == "apt" ]]; then
local pkg="${PACKAGES[$name]}"
if dpkg-query -W "$pkg" &>/dev/null; then
INSTALLED_CACHE["apt:$pkg"]=1
fi
fi
done
else
# Cache all APT packages (original behavior)
while IFS= read -r pkg; do
INSTALLED_CACHE["apt:$pkg"]=1
done < <(dpkg-query -W -f='${Package}\n' 2>/dev/null | sort)
fi
local status pkg
while read -r status pkg; do
[[ "$status" == "installed" ]] && INSTALLED_CACHE["apt:$pkg"]=1
done < <(dpkg-query -W -f='${db:Status-Status} ${Package}\n' 2>/dev/null)
# Cache Snap buntages
if command -v snap &>/dev/null; then
log "Caching Snap buntages..."
if [[ "$selective_mode" == "true" ]]; then
# Only cache packages from included categories
for name in "${!PACKAGES[@]}"; do
local pkg_category="${PKG_CATEGORY[$name]:-}"
local method="${PKG_METHOD[$name]:-}"
# Skip if category is excluded
if [[ -n "${EXCLUDED_CATEGORIES[$pkg_category]:-}" ]]; then
continue
fi
# Only check Snap packages in this section
if [[ "$method" == "snap" ]]; then
local pkg="${PACKAGES[$name]}"
if snap list "$pkg" &>/dev/null; then
INSTALLED_CACHE["snap:$pkg"]=1
fi
fi
done
else
# Cache all Snap packages (original behavior)
while IFS= read -r pkg; do
INSTALLED_CACHE["snap:$pkg"]=1
done < <(snap list 2>/dev/null | awk 'NR>1 {print $1}' | sort)
fi
while IFS= read -r pkg; do
[[ -n "$pkg" ]] && INSTALLED_CACHE["snap:$pkg"]=1
done < <(snap list 2>/dev/null | awk 'NR>1 {print $1}')
fi
# Cache Flatpak buntages
if command -v flatpak &>/dev/null; then
log "Caching Flatpak buntages..."
if [[ "$selective_mode" == "true" ]]; then
# Only cache packages from included categories
for name in "${!PACKAGES[@]}"; do
local pkg_category="${PKG_CATEGORY[$name]:-}"
local method="${PKG_METHOD[$name]:-}"
# Skip if category is excluded
if [[ -n "${EXCLUDED_CATEGORIES[$pkg_category]:-}" ]]; then
continue
fi
# Only check Flatpak packages in this section
if [[ "$method" == "flatpak" ]]; then
local pkg="${PACKAGES[$name]}"
if flatpak list --app | grep -q "$pkg"; then
INSTALLED_CACHE["flatpak:$pkg"]=1
fi
fi
done
else
# Cache all Flatpak packages (original behavior)
while IFS= read -r pkg; do
INSTALLED_CACHE["flatpak:$pkg"]=1
done < <(flatpak list --app --columns=application 2>/dev/null | sort)
fi
while IFS= read -r pkg; do
[[ -n "$pkg" ]] && INSTALLED_CACHE["flatpak:$pkg"]=1
done < <(flatpak list --app --columns=application 2>/dev/null)
fi
local total_cached=${#INSTALLED_CACHE[@]}
log "Buntage cache built with $total_cached entries (selective mode: $selective_mode)"
CACHE_PRIMED=1
log "Buntage cache built with ${#INSTALLED_CACHE[@]} entries"
}
# Function to update cache for a specific buntage
@ -573,7 +520,7 @@ update_package_cache() {
# Update the specific buntage in the cache
case "$method" in
apt)
if dpkg -l "$pkg" 2>/dev/null | grep -q "^ii"; then
if [[ "$(dpkg-query -W -f='${db:Status-Status}' "$pkg" 2>/dev/null)" == "installed" ]]; then
INSTALLED_CACHE["apt:$pkg"]="1"
[[ "$silent" != "true" ]] && log "Cache updated: $name is installed (APT)"
else
@ -591,7 +538,8 @@ update_package_cache() {
fi
;;
flatpak)
if flatpak list --app 2>/dev/null | grep -q "$pkg"; then
# -Fxq: exact whole-line match; a bare grep "$pkg" matched substrings
if flatpak list --app --columns=application 2>/dev/null | grep -Fxq "$pkg"; then
INSTALLED_CACHE["flatpak:$pkg"]="1"
[[ "$silent" != "true" ]] && log "Cache updated: $name is installed (Flatpak)"
else
@ -612,19 +560,33 @@ update_package_cache() {
return 0
}
# Fork-free logging: printf %(..)T is a bash builtin (no $(date) subshell),
# and appending directly avoids the echo|tee pipeline (2 forks per call).
# log() used to fork ~3 processes per line and was called per package per
# menu redraw - thousands of forks on every screen. It also sprayed lines
# onto stderr underneath whiptail. File-only now; set ULTRABUNT_DEBUG=1 to
# mirror logs to stderr again.
log() {
local msg="[$(date +'%Y-%m-%d %H:%M:%S')] $*"
echo "$msg" | tee -a "$LOGFILE" >&2
local ts
printf -v ts '%(%Y-%m-%d %H:%M:%S)T' -1
printf '[%s] %s\n' "$ts" "$*" >> "$LOGFILE" 2>/dev/null || true
[[ "${ULTRABUNT_DEBUG:-0}" == "1" ]] && printf '[%s] %s\n' "$ts" "$*" >&2
return 0
}
log_error() {
local msg="[$(date +'%Y-%m-%d %H:%M:%S')] ERROR: $*"
echo "$msg" | tee -a "$LOGFILE" >&2
local ts
printf -v ts '%(%Y-%m-%d %H:%M:%S)T' -1
printf '[%s] ERROR: %s\n' "$ts" "$*" >> "$LOGFILE" 2>/dev/null || true
printf '[%s] ERROR: %s\n' "$ts" "$*" >&2
}
log_warning() {
local msg="[$(date +'%Y-%m-%d %H:%M:%S')] WARNING: $*"
echo "$msg" | tee -a "$LOGFILE" >&2
local ts
printf -v ts '%(%Y-%m-%d %H:%M:%S)T' -1
printf '[%s] WARNING: %s\n' "$ts" "$*" >> "$LOGFILE" 2>/dev/null || true
[[ "${ULTRABUNT_DEBUG:-0}" == "1" ]] && printf '[%s] WARNING: %s\n' "$ts" "$*" >&2
return 0
}
# Ensure whiptail is available
@ -873,8 +835,14 @@ is_apt_installed() {
return 0
fi
# Fallback to direct dpkg check when cache is missing
if dpkg -l "$pkg" 2>/dev/null | grep -q "^ii"; then
# Once the full scan has run, a miss is authoritative. The old fallback
# forked dpkg for EVERY not-installed package on EVERY menu redraw.
if [[ "$CACHE_PRIMED" == "1" ]]; then
return 1
fi
# Fallback to direct dpkg check only before the cache is built
if [[ "$(dpkg-query -W -f='${db:Status-Status}' "$pkg" 2>/dev/null)" == "installed" ]]; then
INSTALLED_CACHE["apt:$pkg"]=1
return 0
fi
@ -4077,6 +4045,7 @@ CATEGORIES=(
"gaming-platforms:Gaming Platforms"
"gaming-emulators:Gaming Emulators"
"sbc:Single Board Computers & Microcontrollers"
"fun:Fun & Silly Stuff"
)
# Category hotkey mappings (A-Z)
@ -4114,19 +4083,30 @@ declare -A CATEGORY_HOTKEYS=(
# INSTALLATION FUNCTIONS
# ==============================================================================
# Refresh the APT lists, but skip if they were updated recently. Running
# `apt-get update` unconditionally at startup cost 10-60s on old hardware
# every single launch. Pass "force" to update regardless of age.
APT_UPDATE_MAX_AGE_HOURS=6
apt_update() {
local force="${1:-}"
if [[ "$force" != "force" ]]; then
# Newest file under /var/lib/apt/lists tells us when update last ran
local newest
newest=$(find /var/lib/apt/lists -maxdepth 1 -name '*Packages*' -newermt "-${APT_UPDATE_MAX_AGE_HOURS} hours" -print -quit 2>/dev/null || true)
if [[ -n "$newest" ]]; then
log "APT lists are less than ${APT_UPDATE_MAX_AGE_HOURS}h old - skipping update (use --refresh to force)"
return 0
fi
fi
log "Updating APT cache..."
if sudo apt-get update -qq 2>&1 | tee -a "$LOGFILE"; then
log "APT cache updated successfully"
else
local exit_code=$?
if [ $exit_code -eq 0 ]; then
log "APT cache updated with warnings (non-critical)"
else
log "WARNING: APT cache update failed with exit code $exit_code"
log "Continuing anyway - some packages may not be available"
fi
log "WARNING: APT cache update failed - continuing anyway, some packages may not be available"
fi
return 0
}
install_apt_package() {
@ -7294,13 +7274,11 @@ is_package_installed() {
return 1
fi
[[ "$silent" != "true" ]] && log "Buntage '$name' uses method '$method' with buntage name '$pkg'"
# Add error handling for each method
# No logging in here: this runs for every package on every menu redraw,
# and the old per-check log lines were most of the menu lag.
local result=1
case "$method" in
apt)
log "Checking APT installation for '$pkg'"
if is_apt_installed "$pkg"; then
result=0
else
@ -7308,7 +7286,6 @@ is_package_installed() {
fi
;;
snap)
log "Checking Snap installation for '$pkg'"
if is_snap_installed "$pkg"; then
result=0
else
@ -7316,7 +7293,6 @@ is_package_installed() {
fi
;;
flatpak)
log "Checking Flatpak installation for '$pkg'"
if is_flatpak_installed "$pkg"; then
result=0
else
@ -7324,7 +7300,6 @@ is_package_installed() {
fi
;;
custom)
log "Checking custom installation for '$name'"
case "$name" in
docker)
if is_apt_installed "docker-ce"; then
@ -7416,36 +7391,11 @@ is_package_installed() {
# MENU SYSTEM
# ==============================================================================
# Function to refresh package cache in background without logging
refresh_cache_silent() {
local refresh_pid
{
# Update cache for all packages silently
for name in "${!PACKAGES[@]}"; do
update_package_cache "$name" "true" &>/dev/null
done
} &
refresh_pid=$!
# Store the PID for potential cleanup
echo "$refresh_pid" > "/tmp/ultrabunt_cache_refresh.pid" 2>/dev/null || true
}
# Function to check if background cache refresh is complete
is_cache_refresh_complete() {
local pid_file="/tmp/ultrabunt_cache_refresh.pid"
if [[ -f "$pid_file" ]]; then
local pid
pid=$(cat "$pid_file" 2>/dev/null)
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
return 1 # Still running
else
rm -f "$pid_file" 2>/dev/null || true
return 0 # Complete
fi
fi
return 0 # No refresh running
}
# NOTE: the old refresh_cache_silent forked ~600 background processes whose
# cache updates happened in a child shell and were silently thrown away (a
# subshell cannot modify the parent's INSTALLED_CACHE). Pure wasted CPU on
# every menu entry, so it is gone. build_package_cache at startup plus
# update_package_cache after each install/remove keeps the cache correct.
show_category_menu() {
log "Entering show_category_menu function"
@ -7454,9 +7404,6 @@ show_category_menu() {
local -A last_hotkey_selection
local -A hotkey_categories
# Start background cache refresh on first load
refresh_cache_silent
while true; do
log "Building category menu items..."
local menu_items=()
@ -7480,6 +7427,21 @@ show_category_menu() {
menu_items+=("" "(_*_)")
log "Processing categories array with ${#CATEGORIES[@]} entries"
# Count installed/total per category in ONE pass over PACKAGES.
# The old code re-walked all ~600 packages once per category
# (~15,000 iterations with forking status checks) on every redraw.
local -A cat_total=()
local -A cat_installed=()
local name
for name in "${!PACKAGES[@]}"; do
local pkg_category="${PKG_CATEGORY[$name]:-}"
[[ -z "$pkg_category" ]] && continue
cat_total[$pkg_category]=$(( ${cat_total[$pkg_category]:-0} + 1 ))
if is_package_installed "$name" "true"; then
cat_installed[$pkg_category]=$(( ${cat_installed[$pkg_category]:-0} + 1 ))
fi
done
# Build sortable array as name:id:installed:total for alphabetical ordering by name
local sortable_categories=()
for entry in "${CATEGORIES[@]}"; do
@ -7488,31 +7450,10 @@ show_category_menu() {
# Skip excluded categories
if [[ -n "${EXCLUDED_CATEGORIES[$cat_id]:-}" ]]; then
log "Skipping excluded category: $cat_id"
continue
fi
# Count installed buntages in category
local total=0
local installed=0
# Use a safer iteration method
local package_names=()
for name in "${!PACKAGES[@]}"; do
package_names+=("$name")
done
for name in "${package_names[@]}"; do
local pkg_category="${PKG_CATEGORY[$name]:-}"
if [[ "$pkg_category" == "$cat_id" ]]; then
total=$((total + 1))
if is_package_installed "$name" "true"; then
installed=$((installed + 1))
fi
fi
done
sortable_categories+=("$cat_name:$cat_id:$installed:$total")
sortable_categories+=("$cat_name:$cat_id:${cat_installed[$cat_id]:-0}:${cat_total[$cat_id]:-0}")
done
# Sort categories alphabetically by display name
@ -7549,12 +7490,6 @@ show_category_menu() {
index=$((index + 1))
done
# Add refresh indicator if cache is still updating
local refresh_indicator=""
if ! is_cache_refresh_complete; then
refresh_indicator=" 🔄"
fi
# Rebuild menu_items from sorted categories
for item in "${sortable_categories[@]}"; do
local cat_name="${item%%:*}"
@ -7574,7 +7509,7 @@ show_category_menu() {
# Use the hotkey letter as the menu tag so typing the letter jumps
# directly to the row that displays that bracketed hotkey.
# The actual category id is recovered later via hotkey_categories.
menu_items+=("$hotkey" "${hotkey_display}$cat_name [$installed/$total installed]$refresh_indicator")
menu_items+=("$hotkey" "${hotkey_display}$cat_name [$installed/$total installed]")
done
log "Adding additional menu items..."
@ -7584,7 +7519,6 @@ show_category_menu() {
menu_items+=("Q" "(Q) Exit Installer")
log "Calling ui_menu with ${#menu_items[@]} menu items"
log "Menu items array contents: ${menu_items[*]}"
# Add error handling for ui_menu call
local choice=""
@ -7779,7 +7713,7 @@ show_buntage_list() {
if [[ "$pkg_category" == "$category" ]]; then
local status="✗ Not Installed"
local display_text
if is_package_installed "$name"; then
if is_package_installed "$name" "true"; then
status="✓ Installed"
display_text="(.Y.) ${PKG_DESC[$name]:-No description} [$status]"
else
@ -7808,9 +7742,11 @@ show_buntage_list() {
fi
done
# Sort the temp array
IFS=$'\n' temp_array=($(sort <<<"${temp_array[*]}"))
unset IFS
# Sort the temp array (mapfile avoids glob expansion of entries
# containing * or ? that the old unquoted $(sort) suffered from)
if [[ ${#temp_array[@]} -gt 0 ]]; then
mapfile -t temp_array < <(printf '%s\n' "${temp_array[@]}" | sort)
fi
# Convert back to menu_items format
for item in "${temp_array[@]}"; do
@ -7911,18 +7847,25 @@ show_buntage_actions() {
menu_items+=("zback" "(B) ← Back to Buntage List")
local choice
choice=$(ui_menu "Manage: $name" "$info" $DIALOG_HEIGHT $DIALOG_WIDTH $DIALOG_MENU_HEIGHT "${menu_items[@]}")
# || return: without it, Cancel here made set -e kill the whole script
choice=$(ui_menu "Manage: $name" "$info" $DIALOG_HEIGHT $DIALOG_WIDTH $DIALOG_MENU_HEIGHT "${menu_items[@]}") || return 0
case "$choice" in
install)
install_package_with_choice "$name"
ui_msg "Success" "$name installed successfully!"
status="✓ Installed"
if install_package_with_choice "$name"; then
ui_msg "Success" "$name installed successfully!"
status="✓ Installed"
else
ui_msg "Install Failed" "$name did not install cleanly.\n\nCheck $LOGFILE for details."
fi
;;
reinstall)
remove_package "$name"
install_package_with_choice "$name"
ui_msg "Success" "$name reinstalled successfully!"
if install_package_with_choice "$name"; then
ui_msg "Success" "$name reinstalled successfully!"
else
ui_msg "Reinstall Failed" "$name did not reinstall cleanly.\n\nCheck $LOGFILE for details."
fi
;;
remove)
remove_package "$name"
@ -9754,9 +9697,9 @@ bulk_install_category() {
if ! is_package_installed "$name"; then
log "Bulk installing: $name"
if install_package "$name" 2>&1 | tee -a "$LOGFILE"; then
((installed++))
installed=$((installed + 1))
else
((failed++))
failed=$((failed + 1))
log_error "Failed to install: $name"
fi
fi
@ -9807,7 +9750,9 @@ bulk_remove_category() {
esac
;;
esac
((removed++))
# ((removed++)) returns 1 on the 0->1 increment, which set -e
# turned into "whole script exits after first removal"
removed=$((removed + 1))
fi
fi
done
@ -9818,10 +9763,16 @@ bulk_remove_category() {
bulk_install_selected() {
local checklist_items=()
for name in "${!PACKAGES[@]}"; do
if ! is_package_installed "$name"; then
local desc="${PKG_DESC[$name]}"
local cat="${PKG_CATEGORY[$name]}"
# Sort the names first, then build the tag/desc/state triples in order.
# (The old approach joined the triples into lines and sorted ALL lines,
# scrambling tag<->description<->OFF alignment in the checklist.)
local sorted_names=()
mapfile -t sorted_names < <(printf '%s\n' "${!PACKAGES[@]}" | sort)
for name in "${sorted_names[@]}"; do
if ! is_package_installed "$name" "true"; then
local desc="${PKG_DESC[$name]:-No description}"
local cat="${PKG_CATEGORY[$name]:-misc}"
checklist_items+=("$name" "[$cat] $desc" "OFF")
fi
done
@ -9831,11 +9782,6 @@ bulk_install_selected() {
return
fi
# Sort checklist
IFS=$'\n'
checklist_items=($(sort -t$'\t' -k1 <<<"${checklist_items[*]}"))
unset IFS
local selected
selected=$(ui_checklist "Select Packages to Install" \
"Choose packages to install (Space to select, Enter to confirm):" \
@ -9856,9 +9802,9 @@ bulk_install_selected() {
for name in $pkg_list; do
log "Installing selected: $name"
if install_package "$name" 2>&1 | tee -a "$LOGFILE"; then
((installed++))
installed=$((installed + 1))
else
((failed++))
failed=$((failed + 1))
log_error "Failed to install: $name"
fi
done
@ -9869,10 +9815,14 @@ bulk_install_selected() {
bulk_remove_selected() {
local checklist_items=()
for name in "${!PACKAGES[@]}"; do
# Sort names first, then build triples in order (see bulk_install_selected)
local sorted_names=()
mapfile -t sorted_names < <(printf '%s\n' "${!PACKAGES[@]}" | sort)
for name in "${sorted_names[@]}"; do
if is_package_installed "$name" "true"; then
local desc="${PKG_DESC[$name]}"
local cat="${PKG_CATEGORY[$name]}"
local desc="${PKG_DESC[$name]:-No description}"
local cat="${PKG_CATEGORY[$name]:-misc}"
checklist_items+=("$name" "[$cat] $desc" "OFF")
fi
done
@ -9882,11 +9832,6 @@ bulk_remove_selected() {
return
fi
# Sort checklist
IFS=$'\n'
checklist_items=($(sort -t$'\t' -k1 <<<"${checklist_items[*]}"))
unset IFS
local selected
selected=$(ui_checklist "Select Packages to Remove" \
"Choose packages to remove (Space to select, Enter to confirm):" \
@ -9922,7 +9867,7 @@ bulk_remove_selected() {
esac
;;
esac
((removed++))
removed=$((removed + 1))
done
ui_msg "Removal Complete" "Removed: $removed packages\n\nCheck $LOGFILE for details."
@ -12436,9 +12381,17 @@ wordpress_security_hardening() {
return
fi
# Only allow sane domain characters. Without this, entering ".." made
# site_dir=/var/www/.. (= /var) and the file-permissions option then
# ran chown -R www-data / chmod -R over the whole of /var.
if [[ ! "$domain" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]]; then
ui_msg "Invalid Domain" "Domain may only contain letters, digits, dots and hyphens."
return
fi
local site_dir="/var/www/$domain"
if [[ ! -d "$site_dir" ]]; then
if [[ ! -d "$site_dir" || ! -f "$site_dir/wp-config.php" ]]; then
ui_msg "Site Not Found" "WordPress installation not found at $site_dir"
return
fi
@ -12461,8 +12414,12 @@ wordpress_security_hardening() {
ui_msg "Applying Security" "Applying selected security hardening options..."
# Apply selected security measures
# Apply selected security measures. whiptail returns selections as
# "opt1" "opt2" with literal quotes - strip them or no case arm ever
# matches and every option is silently skipped.
for option in $selected; do
option="${option%\"}"
option="${option#\"}"
case "$option" in
"file-permissions")
apply_wp_file_permissions "$site_dir"
@ -15748,8 +15705,10 @@ ensure_persistent_swappiness() {
if [[ ! -f "$file" ]]; then
echo "vm.swappiness=$v" | sudo tee "$file" >/dev/null
else
if grep -Eq '^[[:space:]]*#?[[:space:]]*vm\\.swappiness[[:space:]]*=' "$file"; then
sudo sed -i -E 's/^[[:space:]]*#?[[:space:]]*vm\\.swappiness[[:space:]]*=.*/vm.swappiness='"$v"'/g' "$file"
# NB: \\. inside single quotes is a literal backslash - the old pattern
# never matched, so a duplicate vm.swappiness line was appended per run
if grep -Eq '^[[:space:]]*#?[[:space:]]*vm\.swappiness[[:space:]]*=' "$file"; then
sudo sed -i -E 's/^[[:space:]]*#?[[:space:]]*vm\.swappiness[[:space:]]*=.*/vm.swappiness='"$v"'/g' "$file"
else
echo "vm.swappiness=$v" | sudo tee -a "$file" >/dev/null
fi
@ -15772,7 +15731,9 @@ show_sysctl_swappiness_lines() {
local file="/etc/sysctl.conf"
local lines
if [[ -r "$file" ]]; then
lines=$(grep -n -E 'vm\\.swappiness' "$file" 2>/dev/null)
# || true: grep exits 1 on no match, which set -e turned into a
# whole-script exit when this menu item was opened
lines=$(grep -n -E 'vm\.swappiness' "$file" 2>/dev/null) || true
fi
ui_info "sysctl.conf swappiness" "${lines:-No swappiness entry found in /etc/sysctl.conf}"
}
@ -15985,7 +15946,7 @@ view_log_file() {
"last100" "View Last 100 Lines (Scrollable)" \
"last500" "View Last 500 Lines (Scrollable)" \
"full" "View Full Log File (Scrollable)" \
"tail-follow" "Follow Live Updates (tail -f)")
"tail-follow" "Follow Live Updates (tail -f)") || return 0
case "$choice" in
last100)
@ -16597,10 +16558,14 @@ main() {
log "║ ULTRABUNT ULTIMATE BUNTSTALLER v4.2.0 STARTED ║"
log "╚═══════════════════════════════════════════════╝"
# Update buntage cache
# Update buntage cache (skipped automatically when lists are fresh)
log "Starting APT cache update..."
speak_if_enabled "Updating package cache, please wait..."
apt_update
if [[ "$FORCE_APT_UPDATE" == "1" ]]; then
apt_update force
else
apt_update
fi
log "APT cache update completed"
speak_if_enabled "Package cache updated successfully"