#!/usr/bin/env bash
# ubuntu-desktop-prune.sh
#
# Copyright (C) 2026 LINUXexpert.org
#
# This program is free software: you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation, version 3 of the License.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
# for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program. If not, see .
set -euo pipefail
# =========================
# Version
# =========================
SCRIPT_VERSION="1.1"
# =========================
# Globals / Defaults
# =========================
LOG_DIR="/var/log/ubuntu-to-mint"
mkdir -p "$LOG_DIR"
LOG_FILE="${LOG_DIR}/ubuntu-desktop-prune-$(date +%Y%m%d-%H%M%S).log"
exec > >(tee -a "$LOG_FILE") 2>&1
CMD=""
ASSUME_YES="no"
WITH_RECOMMENDS="no"
SKIP_DM_FIX="no"
ROLLBACK_DIR=""
# For safety gates
MAX_REMOVALS_DEFAULT=75
# =========================
# Pretty output
# =========================
is_tty() { [[ -t 1 ]]; }
if is_tty && command -v tput >/dev/null 2>&1; then
RED="$(tput setaf 1)"
GREEN="$(tput setaf 2)"
YELLOW="$(tput setaf 3)"
BLUE="$(tput setaf 4)"
BOLD="$(tput bold)"
RESET="$(tput sgr0)"
else
RED=""; GREEN=""; YELLOW=""; BLUE=""; BOLD=""; RESET=""
fi
info() { echo "${BLUE}INFO:${RESET} $*"; }
ok() { echo "${GREEN}OK:${RESET} $*"; }
warn() { echo "${YELLOW}WARN:${RESET} $*"; }
die() { echo "${RED}ERROR:${RESET} $*"; exit 1; }
have_cmd() { command -v "$1" >/dev/null 2>&1; }
need_root() {
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
die "This must be run as root. Use: sudo bash $0 ..."
fi
}
on_err() {
local rc=$?
local line="${BASH_LINENO[0]:-unknown}"
echo "ERROR: FAILED at line ${line} (exit ${rc})."
echo "ERROR: Command: ${BASH_COMMAND}"
echo "ERROR: Log: ${LOG_FILE}"
exit "$rc"
}
trap on_err ERR
usage() {
cat </dev/null 2>&1; then
die "APT/dpkg lock is held (lock file: $l). Close package managers and try again."
fi
done
}
apt_fix_broken() {
info "Attempting basic dpkg/apt remediation (best-effort)..."
DEBIAN_FRONTEND=noninteractive dpkg --configure -a || true
DEBIAN_FRONTEND=noninteractive apt-get -y -f install || true
}
apt_opts_common() {
local -a opts
opts=(-y -o Dpkg::Use-Pty=0 -o Acquire::Retries=3)
if [[ "$WITH_RECOMMENDS" != "yes" ]]; then
opts+=(--no-install-recommends)
fi
echo "${opts[@]}"
}
# =========================
# OS / environment checks
# =========================
detect_os_minimal() {
[[ -r /etc/os-release ]] || die "Cannot read /etc/os-release"
# shellcheck disable=SC1091
. /etc/os-release
info "Script v${SCRIPT_VERSION}"
info "Log: ${LOG_FILE}"
info "Detected OS: ${NAME:-unknown} (ID=${ID:-?}, VERSION_ID=${VERSION_ID:-?}, CODENAME=${VERSION_CODENAME:-?})"
# We allow Ubuntu base systems (including “Ubuntu-with-Mint-repos” setups).
if [[ "${ID:-}" != "ubuntu" && "${ID:-}" != "linuxmint" ]]; then
warn "OS ID is not ubuntu/linuxmint. Proceeding anyway, but this script is designed for Ubuntu base systems."
fi
if ! have_cmd apt-get || ! have_cmd dpkg; then
die "apt-get/dpkg not found. This script requires Debian/Ubuntu-style package management."
fi
}
# =========================
# Backup / rollback (limited)
# =========================
backup_state() {
local backup_dir="/root/ubuntu-desktop-prune-backup-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$backup_dir"
info "Creating backup at: ${backup_dir}"
mkdir -p "${backup_dir}/etc"
cp -a /etc/apt "${backup_dir}/etc/" || true
cp -a /etc/lightdm 2>/dev/null "${backup_dir}/etc/" || true
cp -a /etc/X11/default-display-manager 2>/dev/null "${backup_dir}/etc/" || true
cp -a /etc/systemd/system/display-manager.service 2>/dev/null "${backup_dir}/etc/" || true
dpkg-query -W -f='${Package}\t${Version}\n' > "${backup_dir}/dpkg-packages.tsv" || true
apt-mark showmanual > "${backup_dir}/apt-manual.txt" || true
apt-mark showhold > "${backup_dir}/apt-holds.txt" || true
systemctl list-unit-files --state=enabled > "${backup_dir}/enabled-services.txt" 2>/dev/null || true
ok "Backup complete."
echo "$backup_dir"
}
rollback() {
need_root
local dir="${1:-}"
[[ -n "$dir" ]] || die "rollback requires a backup directory argument"
[[ -d "$dir" ]] || die "backup directory not found: $dir"
[[ -d "$dir/etc/apt" ]] || die "backup does not contain etc/apt: $dir/etc/apt"
info "Restoring /etc/apt from: $dir/etc/apt"
rm -rf /etc/apt
cp -a "$dir/etc/apt" /etc/apt
if [[ -d "$dir/etc/lightdm" ]]; then
info "Restoring /etc/lightdm from backup..."
rm -rf /etc/lightdm
cp -a "$dir/etc/lightdm" /etc/lightdm
fi
if [[ -f "$dir/etc/default-display-manager" ]]; then
info "Restoring /etc/X11/default-display-manager from backup..."
mkdir -p /etc/X11
cp -a "$dir/etc/default-display-manager" /etc/X11/default-display-manager
fi
if [[ -f "$dir/etc/display-manager.service" ]]; then
info "Restoring /etc/systemd/system/display-manager.service from backup..."
mkdir -p /etc/systemd/system
cp -a "$dir/etc/display-manager.service" /etc/systemd/system/display-manager.service
systemctl daemon-reload >/dev/null 2>&1 || true
fi
ok "Rollback completed (APT + DM configs)."
warn "Rollback does NOT automatically reinstall removed packages."
info "Now run:"
echo " sudo apt-get update"
echo " sudo apt-get -f install"
}
# =========================
# Display manager hardening (noninteractive-safe)
# =========================
ensure_lightdm_default_noninteractive() {
if [[ "$SKIP_DM_FIX" == "yes" ]]; then
warn "--skip-dm-fix set; skipping LightDM default selection."
return 0
fi
if [[ ! -d /run/systemd/system ]]; then
warn "systemd not detected; skipping LightDM enablement."
return 0
fi
info "Ensuring LightDM is default display manager (debconf preseed + dpkg-reconfigure)..."
DEBIAN_FRONTEND=noninteractive apt-get $(apt_opts_common) install lightdm slick-greeter debconf-utils >/dev/null 2>&1 || true
# Preseed the DM selection so noninteractive installs switch away from gdm3.
# Key: shared/default-x-display-manager
echo "lightdm shared/default-x-display-manager select lightdm" | debconf-set-selections || true
# Apply selection
DEBIAN_FRONTEND=noninteractive dpkg-reconfigure -f noninteractive lightdm >/dev/null 2>&1 || true
# Debian/Ubuntu mechanism
mkdir -p /etc/X11
echo "/usr/sbin/lightdm" > /etc/X11/default-display-manager || true
if have_cmd update-alternatives; then
update-alternatives --set x-display-manager /usr/sbin/lightdm >/dev/null 2>&1 || true
fi
# If gdm3 exists, disable it so it can't take control
if systemctl list-unit-files | awk '{print $1}' | grep -qx 'gdm3.service'; then
systemctl disable --now gdm3 >/dev/null 2>&1 || true
systemctl mask gdm3 >/dev/null 2>&1 || true
fi
# Ensure LightDM enabled
systemctl unmask lightdm >/dev/null 2>&1 || true
systemctl set-default graphical.target >/dev/null 2>&1 || true
systemctl enable lightdm >/dev/null 2>&1 || true
systemctl daemon-reload >/dev/null 2>&1 || true
# Best-effort start
systemctl restart lightdm >/dev/null 2>&1 || true
ok "LightDM default selection/enablement applied."
}
# =========================
# Target selection
# =========================
pkg_installed() {
dpkg-query -W -f='${Status}\n' "$1" 2>/dev/null | grep -q "install ok installed"
}
build_prune_list() {
# Conservative list: remove Ubuntu GNOME session + Ubuntu desktop meta packages + GDM.
# We intentionally avoid removing libs/toolkit packages that Cinnamon/Xfce/MATE may still rely on.
local -a candidates=(
ubuntu-desktop
ubuntu-desktop-minimal
ubuntu-session
ubuntu-session-minimal
gdm3
gnome-shell
gnome-shell-common
gnome-shell-extension-ubuntu-dock
yaru-theme-gnome
yaru-theme-gtk
yaru-theme-icon
yaru-theme-sound
ubuntu-wallpapers
ubuntu-wallpapers-noble
ubuntu-wallpapers-jammy
gnome-software
snap-store
)
local -a selected=()
local p
for p in "${candidates[@]}"; do
if pkg_installed "$p"; then
selected+=("$p")
fi
done
printf '%s\n' "${selected[@]}"
}
show_target_summary() {
local -a pkgs=("$@")
if [[ ${#pkgs[@]} -eq 0 ]]; then
warn "No target packages from the conservative prune list are currently installed."
warn "Nothing to do."
return 1
fi
info "Prune targets (installed):"
printf ' - %s\n' "${pkgs[@]}"
return 0
}
# =========================
# Simulation + safety gates
# (FIXED: exact-match critical package detection)
# =========================
apt_simulate_purge() {
local -a pkgs=("$@")
local sim="/tmp/ubuntu-desktop-prune-sim.$$.txt"
info "Simulating purge (no changes)..."
set +e
DEBIAN_FRONTEND=noninteractive apt-get -s purge --autoremove "${pkgs[@]}" 2>&1 | tee "$sim"
local rc=${PIPESTATUS[0]}
set -e
[[ $rc -eq 0 ]] || die "APT simulation failed (exit $rc). See: $sim"
# Build an exact package list from the simulation.
# APT prints: "Remv :amd64 ..." or "Purg ..."
mapfile -t sim_removed < <(
awk '/^(Remv|Purg)[[:space:]]+/{print $2}' "$sim" \
| sed -E 's/:[a-z0-9]+$//' \
| sort -u
)
# Hard stops: ONLY if these exact packages are removed.
# NOTE: do NOT treat "systemd-*" as a hard stop.
local -a critical_exact=(
sudo
systemd
systemd-sysv
network-manager
openssh-server
linux-image-generic
linux-image-amd64
)
local c r
for c in "${critical_exact[@]}"; do
for r in "${sim_removed[@]}"; do
if [[ "$r" == "$c" ]]; then
die "Simulation shows removal of critical package '${c}'. Aborting. See: $sim"
fi
done
done
local remv_count
remv_count="$(grep -cE '^(Remv|Purg)[[:space:]]' "$sim" || true)"
info "Simulation removal count: ${remv_count}"
if [[ "${ASSUME_YES}" != "yes" && "${remv_count}" -gt "${MAX_REMOVALS_DEFAULT}" ]]; then
die "Simulation wants to remove ${remv_count} packages (too many for 'gentle'). Re-run with --yes only if you reviewed $sim."
fi
ok "Simulation looks acceptable. Review: $sim"
}
# =========================
# Commands
# =========================
doctor() {
need_root
ensure_no_apt_locks
detect_os_minimal
info "Doctor checks..."
apt_fix_broken
local holds
holds="$(apt-mark showhold || true)"
if [[ -n "$holds" ]]; then
warn "Held packages detected (may affect pruning):"
echo "$holds"
else
ok "No held packages detected."
fi
local -a pkgs=()
mapfile -t pkgs < <(build_prune_list || true)
if show_target_summary "${pkgs[@]}"; then
ok "Doctor complete."
else
ok "Doctor complete (nothing obvious to prune)."
fi
}
plan() {
need_root
ensure_no_apt_locks
detect_os_minimal
apt_fix_broken
local -a pkgs=()
mapfile -t pkgs < <(build_prune_list || true)
show_target_summary "${pkgs[@]}" || return 0
# LightDM check (plan-time warning)
if [[ "$SKIP_DM_FIX" != "yes" ]]; then
if ! pkg_installed lightdm; then
warn "lightdm is not installed. prune will attempt to install/activate it first."
fi
if [[ -f /etc/X11/default-display-manager ]]; then
info "Current default-display-manager: $(cat /etc/X11/default-display-manager || true)"
fi
fi
apt_simulate_purge "${pkgs[@]}"
}
prune() {
need_root
ensure_no_apt_locks
detect_os_minimal
if [[ "$ASSUME_YES" != "yes" ]]; then
die "prune is destructive and requires --yes"
fi
apt_fix_broken
local -a pkgs=()
mapfile -t pkgs < <(build_prune_list || true)
show_target_summary "${pkgs[@]}" || return 0
# Ensure DM is safe before removing gdm3/gnome session packages
ensure_lightdm_default_noninteractive
# Re-run simulation immediately before changes
apt_simulate_purge "${pkgs[@]}"
local backup_dir
backup_dir="$(backup_state)"
info "Backup dir: ${backup_dir}"
info "Proceeding with purge..."
DEBIAN_FRONTEND=noninteractive apt-get $(apt_opts_common) purge --autoremove "${pkgs[@]}"
info "Post-purge remediation..."
apt_fix_broken
DEBIAN_FRONTEND=noninteractive apt-get $(apt_opts_common) autoremove --purge || true
DEBIAN_FRONTEND=noninteractive apt-get $(apt_opts_common) clean || true
ok "Prune completed."
info "Log: ${LOG_FILE}"
info "Backup (limited): ${backup_dir}"
info "Next:"
echo " 1) Reboot"
echo " 2) Confirm you can login to your desired session"
echo " 3) If you hit issues, restore via Timeshift/snapshot (preferred) or rollback APT/DM configs:"
echo " sudo bash $0 rollback ${backup_dir}"
}
# =========================
# Arg parsing (command-first OR options-first)
# =========================
parse_args_any_order() {
while [[ $# -gt 0 ]]; do
case "$1" in
doctor|plan|prune|rollback)
[[ -z "$CMD" ]] || die "Multiple commands specified: '${CMD}' and '$1'"
CMD="$1"
shift 1
if [[ "$CMD" == "rollback" ]]; then
if [[ $# -gt 0 && "${1:-}" != --* ]]; then
ROLLBACK_DIR="$1"
shift 1
fi
fi
;;
--yes) ASSUME_YES="yes"; shift 1;;
--with-recommends) WITH_RECOMMENDS="yes"; shift 1;;
--skip-dm-fix) SKIP_DM_FIX="yes"; shift 1;;
-h|--help|help) usage; exit 0;;
*) die "Unknown argument: $1" ;;
esac
done
}
main() {
parse_args_any_order "$@"
[[ -n "$CMD" ]] || { usage; exit 1; }
case "$CMD" in
doctor) doctor ;;
plan) plan ;;
prune) prune ;;
rollback)
[[ -n "$ROLLBACK_DIR" ]] || die "rollback requires a directory argument"
rollback "$ROLLBACK_DIR"
;;
*) die "Unknown command: $CMD" ;;
esac
}
main "$@"