#!/usr/bin/env bash # ubuntu-to-mint-convert-v3.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 -Eeuo pipefail IFS=$'\n\t' ############################################################################### # ubuntu2mint: Ubuntu base + Mint repo + Mint desktop/tooling (best-effort) # NOTE: This does NOT create a supported Linux Mint OS. Ubuntu remains the base. ############################################################################### SCRIPT_NAME="$(basename "$0")" SCRIPT_VERSION="4.6" LOG_DIR="/var/log/ubuntu-to-mint" DEFAULT_MINT_MIRROR="http://packages.linuxmint.com" KEYRING_OUT="/usr/share/keyrings/linuxmint-repo.gpg" SOURCES_OUT="/etc/apt/sources.list.d/official-package-repositories.list" PIN_BASE_OUT="/etc/apt/preferences.d/50-linuxmint-conversion.pref" PIN_STACK_OUT="/etc/apt/preferences.d/51-linuxmint-desktop-stack.pref" SNAP_PREF_OUT="/etc/apt/preferences.d/99-ubuntu2mint-snap.pref" LIGHTDM_PREF_OUT="/etc/lightdm/lightdm.conf.d/90-ubuntu2mint.conf" MAX_ALLOWED_REMOVALS_DEFAULT=40 # runtime vars (always initialized for set -u safety) SUBCMD="${1:-}" shift || true UBUNTU_BASE="" DEFAULT_MINT="" ALLOWED_TARGETS=() # IMPORTANT: array to avoid IFS splitting bug TARGET_MINT="" EDITION="cinnamon" MINT_MIRROR="$DEFAULT_MINT_MIRROR" KEEP_PPAS="no" PRESERVE_SNAP="yes" WITH_RECOMMENDS="no" ASSUME_YES="no" RISK_ACK_FLAG="no" OVERWRITE_KEYRING="no" RECREATE_KEYRING="no" AUTO_FIX="yes" PURGE_CONFLICTING_FLAVORS="yes" MAX_ALLOWED_REMOVALS="$MAX_ALLOWED_REMOVALS_DEFAULT" BACKUP_DIR="" ON_ERROR_BACKUP_HINT="" LOG_FILE="" ############################################################################### # Pretty output helpers ############################################################################### is_tty() { [[ -t 1 ]]; } c_reset=""; c_red=""; c_grn=""; c_ylw=""; c_blu=""; c_bold="" if is_tty; then c_reset="$(printf '\033[0m')" c_red="$(printf '\033[31m')" c_grn="$(printf '\033[32m')" c_ylw="$(printf '\033[33m')" c_blu="$(printf '\033[34m')" c_bold="$(printf '\033[1m')" fi ts() { date -Is; } info() { echo "${c_blu}INFO:${c_reset} $*"; } ok() { echo "${c_grn}OK:${c_reset} $*"; } warn() { echo "${c_ylw}WARN:${c_reset} $*"; } err() { echo "${c_red}ERROR:${c_reset} $*"; } die() { err "$*" [[ -n "${ON_ERROR_BACKUP_HINT:-}" ]] && echo -e "\nRollback hint: ${ON_ERROR_BACKUP_HINT}" exit 1 } have_cmd() { command -v "$1" >/dev/null 2>&1; } ############################################################################### # Logging + traps ############################################################################### setup_logging() { mkdir -p "$LOG_DIR" LOG_FILE="${LOG_DIR}/ubuntu-to-mint-$(date +%Y%m%d-%H%M%S).log" exec > >(tee -a "$LOG_FILE") 2>&1 info "Script v${SCRIPT_VERSION}" info "Log: ${LOG_FILE}" } on_err() { local exit_code=$? local line_no=${BASH_LINENO[0]:-?} local cmd=${BASH_COMMAND:-?} err "FAILED at line ${line_no} (exit ${exit_code})." err "Command: ${cmd}" [[ -n "${LOG_FILE:-}" ]] && err "Log: ${LOG_FILE}" [[ -n "${ON_ERROR_BACKUP_HINT:-}" ]] && echo -e "\nRollback hint: ${ON_ERROR_BACKUP_HINT}" exit "$exit_code" } trap on_err ERR ############################################################################### # Usage ############################################################################### usage() { cat < Mint desktop/tooling" graft. Ubuntu remains the base OS; Mint repos+packages are added with pinning. Usage: sudo bash ${SCRIPT_NAME} doctor sudo bash ${SCRIPT_NAME} plan [options] sudo bash ${SCRIPT_NAME} convert --i-accept-the-risk [options] sudo bash ${SCRIPT_NAME} rollback /root/ubuntu-to-mint-backup-YYYYMMDD-HHMMSS Options: --edition cinnamon|mate|xfce (default: cinnamon) --target Override Mint target codename (validated per Ubuntu base) --mint-mirror (default: ${DEFAULT_MINT_MIRROR}) --keep-ppas Do NOT disable third-party APT sources (not recommended) --preserve-snap / --no-preserve-snap (default: preserve snap) --with-recommends Allow recommended packages during install (default: off) --yes Skip most prompts; in non-interactive shells this is REQUIRED for convert disclaimer --max-removals N Abort if APT simulation removes more than N packages (default: ${MAX_ALLOWED_REMOVALS_DEFAULT}) --overwrite-keyring If ${KEYRING_OUT} exists, overwrite it --recreate-keyring Backup+delete ${KEYRING_OUT} then recreate it --no-auto-fix Do not attempt dpkg/apt repair pre-flight --no-purge-flavor Do not purge conflicting Ubuntu-flavor packages (ubuntucinnamon*, etc.) Notes: * Conversion configures LightDM + slick-greeter and defaults the session to your chosen --edition. * LightDM implies X11 session by default. This script does not attempt Wayland defaults. EOF } ############################################################################### # Argument parsing ############################################################################### parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --edition) EDITION="${2:-}"; shift 2 ;; --target) TARGET_MINT="${2:-}"; shift 2 ;; --mint-mirror) MINT_MIRROR="${2:-}"; shift 2 ;; --keep-ppas) KEEP_PPAS="yes"; shift ;; --preserve-snap) PRESERVE_SNAP="yes"; shift ;; --no-preserve-snap) PRESERVE_SNAP="no"; shift ;; --with-recommends) WITH_RECOMMENDS="yes"; shift ;; --yes) ASSUME_YES="yes"; shift ;; --i-accept-the-risk) RISK_ACK_FLAG="yes"; shift ;; --overwrite-keyring) OVERWRITE_KEYRING="yes"; shift ;; --recreate-keyring) RECREATE_KEYRING="yes"; shift ;; --no-auto-fix) AUTO_FIX="no"; shift ;; --no-purge-flavor) PURGE_CONFLICTING_FLAVORS="no"; shift ;; --max-removals) MAX_ALLOWED_REMOVALS="${2:-}"; shift 2 ;; -h|--help|help) usage; exit 0 ;; *) die "Unknown argument: $1 (use --help)" ;; esac done case "$EDITION" in cinnamon|mate|xfce) : ;; *) die "--edition must be cinnamon|mate|xfce" ;; esac [[ "$MAX_ALLOWED_REMOVALS" =~ ^[0-9]+$ ]] || die "--max-removals must be an integer" } ############################################################################### # APT option builder (array-safe; does NOT rely on IFS splitting) ############################################################################### apt_get_opts_common() { local -n _out="$1" _out=( "-y" "-o" "Dpkg::Use-Pty=0" "-o" "APT::Color=1" "-o" "Acquire::Retries=3" ) if [[ "$WITH_RECOMMENDS" == "no" ]]; then _out+=("--no-install-recommends") fi } apt_get_opts_force_overwrite() { local -n _out="$1" apt_get_opts_common _out _out+=( "-o" "Dpkg::Options::=--force-overwrite" "-o" "Dpkg::Options::=--force-confnew" ) } ############################################################################### # System checks ############################################################################### require_root() { [[ "${EUID}" -eq 0 ]] || die "Run as root (use sudo)."; } check_apt_locks() { local locks=( "/var/lib/dpkg/lock" "/var/lib/dpkg/lock-frontend" "/var/lib/apt/lists/lock" "/var/cache/apt/archives/lock" ) for l in "${locks[@]}"; do if [[ -e "$l" ]] && fuser "$l" >/dev/null 2>&1; then die "APT/dpkg lock active on ${l}. Close Software Updater/apt/dpkg and retry." fi done } detect_os() { [[ -r /etc/os-release ]] || die "Missing /etc/os-release" # shellcheck disable=SC1091 . /etc/os-release local id="${ID:-}" local ver="${VERSION_ID:-}" local codename="${VERSION_CODENAME:-${UBUNTU_CODENAME:-}}" info "Detected OS: ${NAME:-unknown} (ID=${id}, VERSION_ID=${ver}, CODENAME=${codename})" [[ "$id" == "ubuntu" ]] || die "This script only supports Ubuntu as the base (ID=ubuntu)." case "$codename" in noble) UBUNTU_BASE="noble" DEFAULT_MINT="zena" ALLOWED_TARGETS=(zena zara xia wilma) ;; jammy) UBUNTU_BASE="jammy" DEFAULT_MINT="virginia" ALLOWED_TARGETS=(virginia victoria vera vanessa) ;; *) die "Unsupported Ubuntu codename '${codename}'. Supported: noble (24.04), jammy (22.04)." ;; esac if [[ -z "$TARGET_MINT" ]]; then TARGET_MINT="$DEFAULT_MINT" fi local ok_target="no" local t="" for t in "${ALLOWED_TARGETS[@]}"; do if [[ "$TARGET_MINT" == "$t" ]]; then ok_target="yes" break fi done [[ "$ok_target" == "yes" ]] || die "--target '${TARGET_MINT}' not allowed for Ubuntu '${UBUNTU_BASE}'. Allowed: ${ALLOWED_TARGETS[*]}" info "Ubuntu base: ${UBUNTU_BASE} | Target Mint codename: ${TARGET_MINT} | Edition: ${EDITION}" } ############################################################################### # Risk disclaimer (convert only) ############################################################################### convert_disclaimer_gate() { [[ "$SUBCMD" == "convert" ]] || return 0 if [[ "$RISK_ACK_FLAG" != "yes" ]]; then die "convert requires --i-accept-the-risk" fi # If no TTY (CI/non-interactive shell), allow only if --yes is also set. if ! is_tty; then if [[ "$ASSUME_YES" == "yes" ]]; then warn "No interactive TTY detected; proceeding non-interactively because --yes + --i-accept-the-risk were provided." warn "This is UNSUPPORTED and may break corporate agents/EDR/MDM/VPN/compliance tooling." ok "Non-interactive disclaimer acknowledged via flags." return 0 fi die "No interactive TTY detected. Re-run with --yes to acknowledge the disclaimer non-interactively." fi echo echo "${c_red}${c_bold}##############################################${c_reset}" echo "${c_red}${c_bold}# UNSUPPORTED MIGRATION (READ CAREFULLY) #${c_reset}" echo "${c_red}${c_bold}##############################################${c_reset}" echo "${c_red}${c_bold}This script performs an IN-PLACE graft: Ubuntu stays the base OS.${c_reset}" echo "${c_red}${c_bold}It adds Linux Mint repositories + Mint desktop/tooling.${c_reset}" echo echo "${c_red}${c_bold}This is UNSUPPORTED and (frankly) probably dumb to do on a real workstation.${c_reset}" echo "${c_red}${c_bold}Corporate EDR/MDM/VPN/compliance tooling may break and require re-enrollment.${c_reset}" echo "${c_red}${c_bold}A clean install is strongly recommended instead.${c_reset}" echo echo "To continue, type exactly: ${c_bold}I UNDERSTAND${c_reset}" echo -n "> " local resp="" read -r resp if [[ "$resp" != "I UNDERSTAND" ]]; then die "Disclaimer not acknowledged. Aborting." fi ok "Disclaimer acknowledged." } ############################################################################### # APT / dpkg repair ############################################################################### apt_fix_basic() { info "Attempting basic dpkg/apt repair (best-effort)..." export DEBIAN_FRONTEND=noninteractive dpkg --configure -a || true apt-get -y -f install || true apt-get -y --fix-broken install || true apt-get -y update || true ok "Basic repair attempt complete." } doctor_report() { info "Doctor checks:" check_apt_locks local holds="" holds="$(apt-mark showhold 2>/dev/null || true)" if [[ -n "$holds" ]]; then warn "Held packages detected:" echo "$holds" else ok "No held packages detected." fi if dpkg --audit | grep -q .; then warn "dpkg reports issues:" dpkg --audit || true else ok "dpkg --audit clean." fi if apt-get -s check >/dev/null 2>&1; then ok "apt-get check: OK" else warn "apt-get check reports problems." fi ok "Doctor complete." } ############################################################################### # Backup / rollback ############################################################################### backup_system_state() { local backup_dir="/root/ubuntu-to-mint-backup-$(date +%Y%m%d-%H%M%S)" mkdir -p "$backup_dir" ON_ERROR_BACKUP_HINT="sudo bash $SCRIPT_NAME rollback ${backup_dir}" info "Creating backup at: ${backup_dir}" mkdir -p "${backup_dir}/etc" cp -a /etc/apt "${backup_dir}/etc/" || true cp -a /etc/os-release /etc/lsb-release 2>/dev/null "${backup_dir}/etc/" || true cp -a /etc/fstab /etc/hostname /etc/hosts 2>/dev/null "${backup_dir}/etc/" || true cp -a /etc/lightdm 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" || true if have_cmd snap; then snap list > "${backup_dir}/snap-list.txt" || true; fi if have_cmd flatpak; then flatpak list > "${backup_dir}/flatpak-list.txt" || true; fi : > "${backup_dir}/post-convert-validation.txt" || true ok "Backup complete." echo "$backup_dir" } restore_backup() { local backup_dir="$1" [[ -d "$backup_dir" ]] || die "Backup directory not found: $backup_dir" [[ -d "${backup_dir}/etc/apt" ]] || die "Backup missing etc/apt: ${backup_dir}/etc/apt" info "Restoring /etc/apt from backup..." rm -rf /etc/apt cp -a "${backup_dir}/etc/apt" /etc/apt if [[ -d "${backup_dir}/etc/lightdm" ]]; then info "Restoring /etc/lightdm from backup..." rm -rf /etc/lightdm cp -a "${backup_dir}/etc/lightdm" /etc/lightdm fi ok "Restore complete. Run: sudo apt-get update && sudo apt-get -f install" } ############################################################################### # Third-party sources handling (best-effort allowlist for common corp repos) ############################################################################### file_contains_any() { local file="$1"; shift local pat="" for pat in "$@"; do if grep -qiE "$pat" "$file" 2>/dev/null; then return 0 fi done return 1 } is_allowlisted_thirdparty_source() { local file="$1" file_contains_any "$file" \ "crowdstrike" "falcon" \ "paloaltonetworks" "globalprotect" \ "zscaler" \ "vmware" "broadcom" \ "packages\.microsoft\.com" \ "dl\.google\.com/linux" \ "repo\.cloud\.google\.com" \ "nvidia" \ "docker\.com" \ "apt\.releases\.hashicorp\.com" \ "artifactory" "nexus" "jfrog" \ "repo\." "packages\." "apt\." } disable_thirdparty_sources_system() { local backup_dir="$1" local disabled_dir="${backup_dir}/disabled-sources" mkdir -p "$disabled_dir" info "Disabling 3rd-party sources into: ${disabled_dir}" shopt -s nullglob local f="" for f in /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do [[ "$(basename "$f")" == "$(basename "$SOURCES_OUT")" ]] && continue if [[ "$KEEP_PPAS" == "yes" ]]; then continue fi if is_allowlisted_thirdparty_source "$f"; then warn "Keeping allowlisted third-party source: $f" continue fi mv -v "$f" "${disabled_dir}/" || true done shopt -u nullglob ok "Third-party sources handled." } ############################################################################### # Mint keyring: prefer linuxmint-keyring .deb (avoids keyservers) ############################################################################### fetch_linuxmint_keyring_deb() { local out_deb="$1" local base="${MINT_MIRROR%/}/pool/main/l/linuxmint-keyring/" local index_html index_html="$(mktemp)" if ! curl -fsSL "$base" -o "$index_html"; then rm -f "$index_html" die "Unable to fetch linuxmint-keyring directory index from ${base}" fi local candidates candidates="$(grep -oE 'linuxmint-keyring_[0-9][0-9][0-9][0-9][^"]*_all\.deb' "$index_html" | sort -u || true)" rm -f "$index_html" local chosen="" if [[ -n "$candidates" ]]; then chosen="$(printf '%s\n' $candidates | sort -V | tail -n 1)" fi if [[ -z "$chosen" ]]; then chosen="linuxmint-keyring_2022.06.21_all.deb" warn "Could not parse latest linuxmint-keyring from index; falling back to ${chosen}" fi local url="${base}${chosen}" info "Downloading linuxmint-keyring package: ${url}" curl -fsSL "$url" -o "$out_deb" || die "Failed to download ${url}" } install_mint_repo_keyring() { local keyring="$KEYRING_OUT" if [[ -f "$keyring" ]]; then if [[ "$RECREATE_KEYRING" == "yes" ]]; then info "Recreating existing keyring: ${keyring}" cp -a "$keyring" "${keyring}.bak.$(date +%s)" || true rm -f "$keyring" elif [[ "$OVERWRITE_KEYRING" == "yes" ]]; then info "Overwriting existing keyring: ${keyring}" cp -a "$keyring" "${keyring}.bak.$(date +%s)" || true rm -f "$keyring" else info "Keyring already exists: ${keyring} (use --overwrite-keyring or --recreate-keyring to replace)" return 0 fi fi export DEBIAN_FRONTEND=noninteractive local apt_opts=(); apt_get_opts_common apt_opts apt-get "${apt_opts[@]}" update apt-get "${apt_opts[@]}" install curl ca-certificates dpkg-dev gnupg local tmpdir; tmpdir="$(mktemp -d)" chmod 700 "$tmpdir" local deb="${tmpdir}/linuxmint-keyring.deb" fetch_linuxmint_keyring_deb "$deb" dpkg-deb -x "$deb" "$tmpdir/extract" local found_gpg="" found_gpg="$(find "$tmpdir/extract" -type f \( -name '*.gpg' -o -name '*.asc' \) | grep -i 'linuxmint' | head -n 1 || true)" [[ -n "$found_gpg" ]] || die "Could not locate a linuxmint key file inside linuxmint-keyring package." mkdir -p "$(dirname "$keyring")" rm -f "$keyring" || true if [[ "$found_gpg" == *.gpg ]]; then cp -a "$found_gpg" "$keyring" else rm -f "$keyring" || true gpg --batch --dearmor -o "$keyring" "$found_gpg" fi chmod 644 "$keyring" rm -rf "$tmpdir" ok "Mint repo keyring installed at ${keyring}" } ############################################################################### # Ubuntu mirror detection ############################################################################### UBUNTU_ARCHIVE_MIRROR="http://archive.ubuntu.com/ubuntu" UBUNTU_SECURITY_MIRROR="http://security.ubuntu.com/ubuntu" detect_ubuntu_mirrors() { local first="" first="$(grep -RhoE '^deb[[:space:]]+https?://[^[:space:]]+/ubuntu' /etc/apt/sources.list /etc/apt/sources.list.d/*.list 2>/dev/null | head -n 1 || true)" if [[ -n "$first" ]]; then local url url="$(echo "$first" | awk '{print $2}' | sed 's#/ubuntu$##')" UBUNTU_ARCHIVE_MIRROR="${url%/}/ubuntu" fi info "Ubuntu archive mirror: ${UBUNTU_ARCHIVE_MIRROR}" info "Ubuntu security mirror: ${UBUNTU_SECURITY_MIRROR}" } ############################################################################### # Write sources + pinning ############################################################################### write_mint_sources_system() { local list="$SOURCES_OUT" local keyring="$KEYRING_OUT" detect_ubuntu_mirrors info "Writing Mint+Ubuntu sources to ${list}" cat > "$list" < "$PIN_BASE_OUT" <<'EOF' Package: * Pin: origin "packages.linuxmint.com" Pin-Priority: 100 Package: * Pin: release o=Ubuntu Pin-Priority: 500 EOF cat > "$PIN_STACK_OUT" <<'EOF' Package: mint* mintsources* mintupdate* mintsystem* mintstick* mintmenu* mintlocale* mintdrivers* mintreport* mintwelcome* Pin: origin "packages.linuxmint.com" Pin-Priority: 1001 Package: cinnamon* nemo* muffin* cjs* libcjs0* gir1.2-cmenu* libcinnamon* xapp* xapps-common xapp-gtk3-module slick-greeter* lightdm* pix* xviewer* mint-themes* mint-y-icons* mint-x-icons* mint-artwork* Pin: origin "packages.linuxmint.com" Pin-Priority: 1001 EOF cat > "$SNAP_PREF_OUT" </dev/null } apt_sim_parse_removals() { local sim_out="$1" local removed_count removed_count="$(grep -E '^Remv[[:space:]]' "$sim_out" | wc -l | tr -d ' ')" echo "${removed_count:-0}" } apt_sim_list_removed_pkgs() { local sim_out="$1" grep -E '^Remv[[:space:]]' "$sim_out" | awk '{print $2}' | sed 's/:amd64$//' | sort -u || true } apt_sim_abort_if_unsafe() { local sim_out="$1" local removal_count removal_count="$(apt_sim_parse_removals "$sim_out")" info "APT simulation removals: ${removal_count} (threshold ${MAX_ALLOWED_REMOVALS})" if (( removal_count > MAX_ALLOWED_REMOVALS )); then err "APT wants to remove too many packages (${removal_count}). Aborting." err "Review: ${sim_out}" exit 100 fi local removed; removed="$(apt_sim_list_removed_pkgs "$sim_out")" if [[ -n "$removed" ]]; then local crit; crit="$(critical_packages_list)" local p="" while IFS= read -r p; do if grep -qxF "$p" <<<"$crit"; then err "APT simulation wants to remove critical package: ${p}" err "Aborting. Review: ${sim_out}" exit 100 fi done <<<"$removed" fi ok "Simulation safety checks passed." } ############################################################################### # Conflicting packages (Ubuntu flavors & known dpkg conflicts) ############################################################################### purge_conflicting_flavors_best_effort() { [[ "$PURGE_CONFLICTING_FLAVORS" == "yes" ]] || return 0 local conflicts=( "ubuntucinnamon-desktop" "ubuntucinnamon-environment" "ubuntucinnamon-settings" "ubuntucinnamon-wallpapers" "ubuntucinnamon-*" "cinnamon-desktop-environment" ) local installed_any="no" local c="" for c in "${conflicts[@]}"; do if dpkg -l 2>/dev/null | awk '{print $2}' | grep -qxE "${c//\*/.*}"; then installed_any="yes" break fi done [[ "$installed_any" == "yes" ]] || return 0 warn "Purging potentially conflicting Ubuntu flavor packages (best-effort)..." export DEBIAN_FRONTEND=noninteractive local apt_opts=(); apt_get_opts_common apt_opts apt-get "${apt_opts[@]}" purge ubuntucinnamon-desktop ubuntucinnamon-\* cinnamon-desktop-environment || true apt-get "${apt_opts[@]}" autoremove || true ok "Conflict purge attempt complete." } remove_software_properties_gtk_best_effort() { if dpkg -s software-properties-gtk >/dev/null 2>&1; then warn "Removing software-properties-gtk to avoid dpkg file overwrite conflicts with Mint tooling (best-effort)..." export DEBIAN_FRONTEND=noninteractive local apt_opts=(); apt_get_opts_common apt_opts apt-get "${apt_opts[@]}" remove software-properties-gtk || true ok "software-properties-gtk removal attempted." fi } ############################################################################### # Display manager / session defaults (per edition) ############################################################################### session_name_for_edition() { case "$EDITION" in cinnamon) echo "cinnamon" ;; xfce) echo "xfce" ;; mate) echo "mate" ;; *) echo "cinnamon" ;; esac } ensure_x11_common_present() { export DEBIAN_FRONTEND=noninteractive local apt_opts=(); apt_get_opts_common apt_opts apt-get "${apt_opts[@]}" install --reinstall x11-common || true } configure_lightdm_and_session() { local sess; sess="$(session_name_for_edition)" export DEBIAN_FRONTEND=noninteractive local apt_opts=(); apt_get_opts_common apt_opts info "Installing display manager components (LightDM + slick-greeter)..." apt-get "${apt_opts[@]}" install lightdm slick-greeter || die "Failed to install LightDM stack" ensure_x11_common_present info "Configuring LightDM defaults for session '${sess}'..." mkdir -p "$(dirname "$LIGHTDM_PREF_OUT")" cat > "$LIGHTDM_PREF_OUT" </dev/null 2>&1; then warn "Disabling gdm3 to prefer LightDM..." systemctl disable --now gdm3 || true fi systemctl enable --now lightdm || true ok "LightDM configured. Default session set to '${sess}'." } ############################################################################### # Timeshift snapshot (best-effort) ############################################################################### timeshift_snapshot_best_effort() { if have_cmd timeshift; then info "Timeshift detected. Attempting pre-change snapshot (best-effort)..." timeshift --create --comments "pre ubuntu->mint $(date -Is)" --tags D || warn "Timeshift snapshot failed (may not be configured)." else warn "Timeshift not installed. Strongly recommended to snapshot/backup before converting." fi } ############################################################################### # Plan mode (dry-run using temporary APT root; does not touch /etc/apt) ############################################################################### make_temp_apt_root() { local tmp; tmp="$(mktemp -d)" mkdir -p "$tmp/etc/apt/sources.list.d" "$tmp/etc/apt/preferences.d" "$tmp/usr/share/keyrings" mkdir -p "$tmp/var/lib/apt/lists/partial" "$tmp/var/cache/apt/archives/partial" echo "$tmp" } apt_cmd_with_root() { local root="$1" echo "-o Dir::Etc=${root}/etc/apt -o Dir::Etc::sourcelist=${root}/etc/apt/sources.list -o Dir::Etc::sourceparts=${root}/etc/apt/sources.list.d -o Dir::Etc::preferencesparts=${root}/etc/apt/preferences.d -o Dir::State=${root}/var/lib/apt -o Dir::State::Lists=${root}/var/lib/apt/lists -o Dir::Cache=${root}/var/cache/apt -o Dir::Cache::archives=${root}/var/cache/apt/archives -o APT::Get::List-Cleanup=0" } plan_mode() { require_root setup_logging parse_args "$@" detect_os check_apt_locks info "Plan mode: creating temporary APT environment (no changes to /etc/apt)..." local root; root="$(make_temp_apt_root)" local plan_log="${LOG_DIR}/plan-$(date +%Y%m%d-%H%M%S).txt" local tmp_keyring="${root}/usr/share/keyrings/linuxmint-repo.gpg" local old_keyring_out="$KEYRING_OUT" KEYRING_OUT="$tmp_keyring" install_mint_repo_keyring KEYRING_OUT="$old_keyring_out" local tmp_sources="${root}/etc/apt/sources.list.d/official-package-repositories.list" local tmp_pref_base="${root}/etc/apt/preferences.d/50-linuxmint-conversion.pref" local tmp_pref_stack="${root}/etc/apt/preferences.d/51-linuxmint-desktop-stack.pref" detect_ubuntu_mirrors cat > "$tmp_sources" < "$tmp_pref_base" <<'EOF' Package: * Pin: origin "packages.linuxmint.com" Pin-Priority: 100 Package: * Pin: release o=Ubuntu Pin-Priority: 500 EOF cat > "$tmp_pref_stack" <<'EOF' Package: mint* mintsources* mintupdate* mintsystem* mintstick* mintmenu* mintlocale* mintdrivers* mintreport* mintwelcome* Pin: origin "packages.linuxmint.com" Pin-Priority: 1001 Package: cinnamon* nemo* muffin* cjs* libcjs0* gir1.2-cmenu* libcinnamon* xapp* xapps-common xapp-gtk3-module slick-greeter* lightdm* pix* xviewer* mint-themes* mint-y-icons* mint-x-icons* mint-artwork* Pin: origin "packages.linuxmint.com" Pin-Priority: 1001 EOF export DEBIAN_FRONTEND=noninteractive local apt_opts=(); apt_get_opts_common apt_opts # shellcheck disable=SC2206 local root_opts=($(apt_cmd_with_root "$root")) info "Plan: apt-get update (temp root)..." apt-get "${root_opts[@]}" "${apt_opts[@]}" update | tee "$plan_log" >/dev/null local meta_pkg="mint-meta-${EDITION}" local pkgs=( "$meta_pkg" mint-meta-core mintsystem mintupdate mintsources mint-meta-codecs ) info "Plan: Simulation (temp root) of install: ${pkgs[*]}" apt-get "${root_opts[@]}" -s "${apt_opts[@]}" install "${pkgs[@]}" | tee -a "$plan_log" >/dev/null || true info "Plan written to: ${plan_log}" ok "Plan complete (no system APT changes made)." rm -rf "$root" } ############################################################################### # Convert ############################################################################### convert_mode() { require_root setup_logging parse_args "$@" detect_os check_apt_locks convert_disclaimer_gate [[ "$AUTO_FIX" == "yes" ]] && apt_fix_basic timeshift_snapshot_best_effort BACKUP_DIR="$(backup_system_state)" info "Backup directory: ${BACKUP_DIR}" if [[ "$KEEP_PPAS" != "yes" ]]; then disable_thirdparty_sources_system "$BACKUP_DIR" else warn "--keep-ppas enabled; third-party sources remain active (higher risk)." fi install_mint_repo_keyring write_mint_sources_system write_mint_pinning_system if [[ "$PRESERVE_SNAP" == "yes" ]]; then ok "Snap preservation enabled." else warn "Snap preservation disabled." fi purge_conflicting_flavors_best_effort remove_software_properties_gtk_best_effort export DEBIAN_FRONTEND=noninteractive local apt_opts=(); apt_get_opts_common apt_opts info "APT update..." apt-get "${apt_opts[@]}" update local sim_out="${BACKUP_DIR}/apt-simulate-install.txt" local meta_pkg="mint-meta-${EDITION}" local pkgs=( "$meta_pkg" mint-meta-core mintsystem mintupdate mintsources mint-meta-codecs ) info "Simulation (safety check) of install: ${pkgs[*]}" apt_simulate_install "$sim_out" "${pkgs[@]}" || true apt_sim_abort_if_unsafe "$sim_out" info "Installing Mint stack..." local apt_force=(); apt_get_opts_force_overwrite apt_force if ! apt-get "${apt_opts[@]}" install "${pkgs[@]}"; then warn "Initial install failed; retrying with dpkg --force-overwrite (best-effort)..." apt-get "${apt_force[@]}" install "${pkgs[@]}" || die "Mint stack install failed even with force-overwrite." fi ok "Mint stack installed." configure_lightdm_and_session post_convert_validate ok "Conversion complete." echo echo "${c_bold}Recommended next step:${c_reset} reboot and select '${EDITION}' session in LightDM (if prompted)." } ############################################################################### # Post-conversion validation ############################################################################### write_validation_line() { local out="$1"; shift echo "[$(date -Is)] $*" >> "$out" } post_convert_validate() { local out="${BACKUP_DIR}/post-convert-validation.txt" : > "$out" || true info "Post-conversion validation writing to: ${out}" write_validation_line "$out" "Ubuntu base: ${UBUNTU_BASE}" write_validation_line "$out" "Mint target: ${TARGET_MINT}" write_validation_line "$out" "Edition: ${EDITION}" write_validation_line "$out" "Mint mirror: ${MINT_MIRROR}" write_validation_line "$out" "Keyring: ${KEYRING_OUT}" local check_pkgs=( "mintsystem" "mintupdate" "mintsources" "lightdm" "slick-greeter" ) if [[ "$EDITION" == "cinnamon" ]]; then check_pkgs+=( "cinnamon-session" "muffin" "nemo" "cjs" ) elif [[ "$EDITION" == "xfce" ]]; then check_pkgs+=( "xfce4-session" "xfwm4" ) elif [[ "$EDITION" == "mate" ]]; then check_pkgs+=( "mate-session-manager" ) fi local p="" for p in "${check_pkgs[@]}"; do if dpkg -s "$p" >/dev/null 2>&1; then write_validation_line "$out" "OK pkg: $p" else write_validation_line "$out" "MISSING pkg: $p" fi done if apt-get -s check >/dev/null 2>&1; then write_validation_line "$out" "APT check: OK" else write_validation_line "$out" "APT check: FAILED" fi if systemctl is-enabled lightdm >/dev/null 2>&1; then write_validation_line "$out" "LightDM: enabled" else write_validation_line "$out" "LightDM: NOT enabled" fi if [[ -f /etc/X11/Xsession ]]; then write_validation_line "$out" "/etc/X11/Xsession: present" else write_validation_line "$out" "/etc/X11/Xsession: MISSING" fi local maybe_services=( "falcon-sensor" "crowdstrike-falcon-sensor" "gpd" "GlobalProtect" "globalprotect" ) local s="" for s in "${maybe_services[@]}"; do if systemctl list-units --type=service --all | grep -qi "$s"; then write_validation_line "$out" "NOTE service match: ${s}" fi done ok "Post-conversion validation complete." } ############################################################################### # Rollback ############################################################################### rollback_mode() { require_root setup_logging local backup_dir="${1:-}" [[ -n "$backup_dir" ]] || die "Usage: ${SCRIPT_NAME} rollback /root/ubuntu-to-mint-backup-YYYYMMDD-HHMMSS" restore_backup "$backup_dir" } ############################################################################### # Main ############################################################################### main() { case "$SUBCMD" in doctor) require_root setup_logging parse_args "$@" detect_os check_apt_locks [[ "$AUTO_FIX" == "yes" ]] && apt_fix_basic doctor_report ;; plan) plan_mode "$@" ;; convert) convert_mode "$@" ;; rollback) rollback_mode "$@" ;; ""|help|-h|--help) usage ;; *) usage die "Unknown subcommand: ${SUBCMD}" ;; esac } main "$@"