Ubuntu 22.04/24.04/26.04 一括構築スクリプト


#!/bin/bash
# ============================================================
# setup.sh : Ubuntu 22.04 / 24.04 / 26.04(Lubuntu 含む)環境構築
#
# 【実行方法】
#   通常の利用者(sudo 可能なアカウント)自身で実行する。root では実行しない。
#     ./setup.sh              通常実行(未完了ブロックから再開)
#     ./setup.sh --remaster   配布イメージ作成前の整理(B90)も実行する
#     ./setup.sh --reset      進捗を破棄し,最初のブロックから実行する
#     ./setup.sh --check      導入結果の確認(B95)だけを実行する(状態を変更しない)
#
# 【自己完結】
#   外部スクリプトや配布サーバ上の設定ファイルは取得しない。利用者設定は
#   すべて本スクリプト内に記述する(B70 のヒアドキュメント)。組織固有の
#   内容がある場合は当該箇所を直接書き換えること。
#
# 【対応する記事】
#   導入するアプリの一覧は「Ubuntu 22.04/24.04/26.04 開発・研究環境構築
#   ガイド」と一致させてある。相違は,アカウント連携・ライセンス同意・配布
#   URL の不安定さのため自動化しないものだけであり,いずれも確認事項に記録
#   する。該当するのは次の 7 件である。
#     Unreal Engine / Intel oneAPI MKL / MakeHuman /
#     Steam・Lutris・Heroic Games Launcher /
#     SQLiteStudio / SQL Workbench/J / BlueJ・Greenfoot
#
# 【ブロック分割の意味】
#   本スクリプトは役割ごとに以下のブロックへ分かれる。後のブロックは前の
#   ブロックの成果に依存する。
#     B10 システム基盤   ミラー変更・全体更新・基本ツール・時刻・ロケール
#                                                           → 以降すべての前提
#     B20 リポジトリ登録 サードパーティ APT リポジトリ      → B30 の前提
#     B30 アプリ導入(apt)                                   → B40/B60/B70 の前提
#     B40 アプリ導入(ソース・外部配布物)                     → B70 の前提(pyenv)
#     B50 不要パッケージ削除
#     B60 システム設定・不要サービス停止
#     B70 ユーザ設定
#     B80 不要ファイル削除・後処理
#     B90 リマスター準備(--remaster 指定時のみ)
#     B95 導入結果の確認(ブロックではない。状態を変更しない)
#   ロケールは B10 で確定させる。B30 の check-language-support が現在の
#   ロケール設定をもとに必要パッケージを判定するためである。
#
# 【エラーの扱い】
#   普通のエラー : 記録して続行。末尾で一覧表示し,対処は人間が判断する。
#   深刻なエラー : 後続ブロックの失敗が確実な場合のみ。当該ブロックの末尾で
#                  停止し,そのブロックは未完了のまま残す。原因を人手で除去
#                  して本スクリプトを再実行すれば,当該ブロックの先頭から
#                  再開する。エラーによって処理内容を変えることはしない。
#
# 【リポジトリの提供状況】
#   サードパーティ APT リポジトリは,登録前に当該コード名向けの Release を
#   取得できるかどうかだけを確認する。取得できない場合は登録せず,関連する
#   パッケージも導入せず,確認事項として記録する。これは環境条件による分岐
#   であり,エラーによる分岐ではない。
#
# 【APT ロックへの対処】
#   実行前に自動更新系ユニットを停止し,全 apt-get 呼び出しに
#   DPkg::Lock::Timeout を付けてロックの解放を待つ。ロックファイルの削除や
#   apt プロセスの強制終了は dpkg データベースを破壊するため行わない。
#   停止した自動更新ユニットは全ブロック完了後に再開する。深刻なエラーで
#   停止した場合は停止したままとし,再実行時に改めて停止処理を行う。
# ============================================================

set -u

SCRIPT_PATH="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
STATE_DIR="/var/lib/setup-sh"
STATE_FILE="${STATE_DIR}/completed-blocks"

DO_REMASTER=0
DO_RESET=0
DO_CHECK=0

export DEBIAN_FRONTEND=noninteractive
export APT_LISTCHANGES_FRONTEND=none
export NEEDRESTART_MODE=a
export NEEDRESTART_SUSPEND=1

APT_OPTS=(
    -y
    -o Acquire::Retries=3
    -o DPkg::Lock::Timeout=1800
    -o Dpkg::Options::=--force-confdef
    -o Dpkg::Options::=--force-confold
)

FAILED_TASKS=()      # 普通のエラー(全ブロック通算)
MANUAL_NOTES=()      # 人手での確認事項
FATAL_TASKS=()       # 深刻なエラー(現ブロック分)
CHECK_NG=()          # B95 で NG となった項目
CURRENT_BLOCK=""
KEEPALIVE_PID=""

OS_ID=""; OS_VERSION_ID=""; OS_CODENAME=""; IS_LUBUNTU=0; HAS_NVIDIA=0
TARGET_USER=""; TARGET_HOME=""

# Python 環境(B40 で構築し B95 で確認する)
PY_MAIN_VERSION="3.13.15"          # 主環境
PY_MP_VERSION="3.12.13"            # MediaPipe 用(新しい Python 向けホイールが未提供のため)
VENV_MAIN="${HOME}/venvs/myproject_env"
VENV_MP="${HOME}/venvs/mediapipe_env"

# ------------------------------------------------------------
# 記録と表示
# ------------------------------------------------------------
info()           { echo "[INFO ] $*"; }
step()           { echo "------------------------------------------------------------"; echo "[STEP ] $*"; }
record_failure() { FAILED_TASKS+=("${CURRENT_BLOCK} / $1"); echo "[FAIL ] $1"; }
record_manual()  { MANUAL_NOTES+=("$1");                    echo "[NOTE ] $1"; }
record_fatal()   { FATAL_TASKS+=("$1");                     echo "[FATAL] $1"; }

report() {
    echo
    echo "============================================================"
    echo "実行結果レポート"
    echo "============================================================"
    echo "OS         : ${OS_ID} ${OS_VERSION_ID} (${OS_CODENAME})  Lubuntu=${IS_LUBUNTU}  NVIDIA=${HAS_NVIDIA}"
    echo "対象ユーザ : ${TARGET_USER} (${TARGET_HOME})"
    if [ -f "${STATE_FILE}" ]; then
        echo "完了ブロック: $(tr '\n' ' ' < "${STATE_FILE}")"
    fi
    echo
    if [ "${#FAILED_TASKS[@]}" -eq 0 ]; then
        echo "-- 普通のエラー: なし"
    else
        echo "-- 普通のエラー(${#FAILED_TASKS[@]} 件): 処理は続行済み。内容を確認し人手で対応すること"
        for t in "${FAILED_TASKS[@]}"; do echo "   - ${t}"; done
    fi
    echo
    if [ "${#CHECK_NG[@]}" -gt 0 ]; then
        echo "-- 導入確認(B95)で NG(${#CHECK_NG[@]} 件): 該当アプリの導入手順を確認すること"
        for t in "${CHECK_NG[@]}"; do echo "   - ${t}"; done
        echo
    fi
    if [ "${#MANUAL_NOTES[@]}" -gt 0 ]; then
        echo "-- 人手での確認事項"
        for t in "${MANUAL_NOTES[@]}"; do echo "   - ${t}"; done
        echo
    fi
    echo "============================================================"
}

# 前提条件が満たされない場合(ブロック開始前)
abort() {
    echo "[FATAL] $1"
    report
    echo "原因を除去してから ${SCRIPT_PATH} を再実行すること。"
    exit 1
}

# ------------------------------------------------------------
# ブロック管理(進捗の記録と再開)
# ------------------------------------------------------------
block_completed() { [ -f "${STATE_FILE}" ] && grep -qx "$1" "${STATE_FILE}"; }
block_mark()      { echo "$1" | sudo tee -a "${STATE_FILE}" >/dev/null; }

run_block() {
    local id="$1" title="$2" func="$3"
    if block_completed "${id}"; then
        echo "[SKIP ] ${id} ${title} : 完了済みのため実行しない"
        return 0
    fi
    echo
    echo "============================================================"
    echo "[BLOCK] ${id} ${title}"
    echo "============================================================"
    CURRENT_BLOCK="${id}"
    FATAL_TASKS=()
    "${func}"
    if [ "${#FATAL_TASKS[@]}" -gt 0 ]; then
        echo
        echo "============================================================"
        echo "[STOP ] ブロック ${id} (${title}) で深刻なエラーが発生した"
        echo "============================================================"
        for t in "${FATAL_TASKS[@]}"; do echo "   ! ${t}"; done
        echo
        echo "後続ブロックの失敗が確実なため,ここで停止する。"
        echo "APT の自動更新は停止したままである。"
        report
        echo "対処後に ${SCRIPT_PATH} を再実行すると,ブロック ${id} の先頭から再開する。"
        exit 1
    fi
    block_mark "${id}"
    echo "[DONE ] ${id} ${title}"
}

# ------------------------------------------------------------
# 共通処理
# ------------------------------------------------------------
unit_exists() { systemctl list-unit-files --no-legend "$1" 2>/dev/null | grep -q .; }

# 既存ファイルを一度だけ .DIST として退避する(何度実行してもよい)
backup_once() {
    [ -e "$1" ]      || return 0
    [ -e "$1.DIST" ] && return 0
    sudo cp -a "$1" "$1.DIST"
}

apt_prepare() {
    local u
    for u in apt-daily.timer apt-daily-upgrade.timer \
             apt-daily.service apt-daily-upgrade.service \
             unattended-upgrades.service packagekit.service; do
        unit_exists "$u" || continue
        sudo systemctl stop "$u" || return 1
    done
    return 0
}

apt_restore() {
    local u
    for u in apt-daily.timer apt-daily-upgrade.timer unattended-upgrades.service; do
        unit_exists "$u" || continue
        sudo systemctl start "$u" || record_failure "自動更新ユニットの再開: $u"
    done
}

apt_update()     { sudo -E apt-get "${APT_OPTS[@]}" update; }
apt_upgrade()    { sudo -E apt-get "${APT_OPTS[@]}" dist-upgrade; }
apt_fix()        { sudo -E apt-get "${APT_OPTS[@]}" -f install; }
apt_autoremove() { sudo -E apt-get "${APT_OPTS[@]}" --purge autoremove; }
apt_autoclean()  { sudo -E apt-get "${APT_OPTS[@]}" autoclean; }
apt_clean()      { sudo -E apt-get "${APT_OPTS[@]}" clean; }

# 1 パッケージずつ導入し,失敗は当該パッケージのみ記録する
apt_install() {
    local p
    for p in "$@"; do
        sudo -E apt-get "${APT_OPTS[@]}" install "$p" || record_failure "導入: $p"
    done
}

# ローカルの deb を依存解決付きで導入する
apt_install_deb() { sudo -E apt-get "${APT_OPTS[@]}" install "$1"; }

# 導入済みのものだけ削除する(何度実行してもよい)
apt_purge() {
    local p
    for p in "$@"; do
        dpkg-query -W -f='${Status}' "$p" 2>/dev/null | grep -q "ok installed" || continue
        sudo -E apt-get "${APT_OPTS[@]}" --purge remove "$p" || record_failure "削除: $p"
    done
}

# パターンに一致する導入済みパッケージを列挙して削除する(apt に * を渡さない)
apt_purge_glob() {
    local pat pkgs
    for pat in "$@"; do
        pkgs="$(dpkg-query -W -f='${Package} ${Status}\n' "${pat}" 2>/dev/null \
                | awk '$NF=="installed"{print $1}')"
        [ -n "${pkgs}" ] || continue
        apt_purge ${pkgs}
    done
}

pkg_installed() { dpkg-query -W -f='${Status}' "$1" 2>/dev/null | grep -q "ok installed"; }

snap_install() {
    local p
    command -v snap >/dev/null 2>&1 || { info "snapd が無いため snap パッケージは導入しない: $*"; return 0; }
    for p in "$@"; do
        snap list "$p" >/dev/null 2>&1 && continue
        sudo snap install "$p" || record_failure "snap 導入: $p"
    done
}

service_disable() {
    local u
    for u in "$@"; do
        unit_exists "$u" || continue
        sudo systemctl disable --now "$u" || record_failure "サービス停止: $u"
    done
}

fetch() { curl -fsSL --retry 3 --retry-delay 2 -o "$2" "$1"; }

add_keyring() {   # add_keyring <URL> <出力先>
    local url="$1" dst="$2" tmp rc
    tmp="$(mktemp)"
    fetch "${url}" "${tmp}" || { rm -f "${tmp}"; return 1; }
    sudo install -d -m 0755 /etc/apt/keyrings
    sudo gpg --batch --yes --dearmor -o "${dst}" "${tmp}"; rc=$?
    rm -f "${tmp}"
    sudo chmod 0644 "${dst}"
    return ${rc}
}

add_source() { echo "$2" | sudo tee "/etc/apt/sources.list.d/$1" >/dev/null; }

# 当該コード名向けの配布が存在するかだけを見る(環境条件の確認)
repo_available() { curl -fsS --max-time 20 --retry 2 --retry-delay 2 -o /dev/null "$1"; }

# add_repo <名前> <鍵URL> <鍵出力先> <Release URL> <ソース行> <ファイル名>
add_repo() {
    local name="$1" key_url="$2" key_dst="$3" rel_url="$4" srcline="$5" file="$6"
    if ! repo_available "${rel_url}"; then
        sudo rm -f "/etc/apt/sources.list.d/${file}"
        record_manual "${name}: ${OS_CODENAME} 向けリポジトリが提供されていない(${rel_url} を取得できない)。登録せず,関連パッケージも導入しない。公式の提供状況を確認すること。"
        return 1
    fi
    add_keyring "${key_url}" "${key_dst}" || { record_failure "${name} 公開鍵の取得"; return 1; }
    add_source "${file}" "${srcline}"
    return 0
}

repo_registered() { [ -f "/etc/apt/sources.list.d/$1" ]; }

# Python 仮想環境
venv_create() {   # venv_create <パス> <pyenv のバージョン>
    local dst="$1" ver="$2"
    [ -x "${dst}/bin/python" ] && return 0
    [ -x "${PYENV_ROOT}/versions/${ver}/bin/python" ] || return 1
    mkdir -p "$(dirname "${dst}")"
    "${PYENV_ROOT}/versions/${ver}/bin/python" -m venv "${dst}"
}

venv_pip_install() {   # venv_pip_install <venv> <パッケージ...>
    local venv="$1"; shift
    local p
    [ -x "${venv}/bin/pip" ] || { record_failure "仮想環境が無い: ${venv}"; return 1; }
    for p in "$@"; do
        "${venv}/bin/pip" install --upgrade "$p" || record_failure "pip 導入(${venv##*/}): $p"
    done
}

# ------------------------------------------------------------
# 環境の判定(--check でも用いる)
# ------------------------------------------------------------
detect_env() {
    . /etc/os-release
    OS_ID="${ID}"
    OS_VERSION_ID="${VERSION_ID}"
    OS_CODENAME="${VERSION_CODENAME:-${UBUNTU_CODENAME:-}}"
    TARGET_USER="$(id -un)"
    TARGET_HOME="${HOME}"
    if [ -x /usr/bin/lxqt-session ] || pkg_installed lubuntu-desktop; then
        IS_LUBUNTU=1
    fi
    if lspci 2>/dev/null | grep -qi 'nvidia'; then
        HAS_NVIDIA=1
    fi
    info "OS: ${OS_ID} ${OS_VERSION_ID} (${OS_CODENAME})  Lubuntu=${IS_LUBUNTU}  NVIDIA=${HAS_NVIDIA}"
    info "対象ユーザ: ${TARGET_USER} (${TARGET_HOME})"
}

# ------------------------------------------------------------
# 事前確認(毎回実行する。ブロックではない)
# ------------------------------------------------------------
preflight() {
    [ "$(id -u)" -ne 0 ] || abort "root で実行されている。ユーザ設定が root に適用されるため,対象の利用者アカウントで実行すること。"

    detect_env

    # 22.04=jammy / 24.04=noble / 26.04=resolute の三種のみを対象とする
    case "${OS_VERSION_ID}" in
        22.04|24.04|26.04) ;;
        *) abort "対象外の OS (${OS_ID} ${OS_VERSION_ID})。本スクリプトは Ubuntu 22.04 / 24.04 / 26.04 専用である。" ;;
    esac
    case "${OS_CODENAME}" in
        jammy|noble|resolute) ;;
        *) abort "コード名 ${OS_CODENAME} は対象外である。" ;;
    esac

    sudo -v || abort "sudo が利用できない。"
    # 長時間のビルド中に sudo の認証が失効しないよう維持する
    ( while true; do sudo -n true; sleep 60; done ) >/dev/null 2>&1 &
    KEEPALIVE_PID=$!
    trap 'kill "${KEEPALIVE_PID}" 2>/dev/null' EXIT

    sudo install -d -m 0755 "${STATE_DIR}" || abort "状態ディレクトリ ${STATE_DIR} を作成できない。"
    if [ "${DO_RESET}" -eq 1 ]; then
        sudo rm -f "${STATE_FILE}"
        info "進捗を破棄した。最初のブロックから実行する。"
    fi
    sudo touch "${STATE_FILE}"

    apt_prepare || abort "APT 自動更新ユニットを停止できない。ロックが解放されないため続行しない。"
    info "APT 自動更新ユニットを停止した(全ブロック完了後に再開する)。"
}

# ============================================================
# B10 システム基盤
# ============================================================
block_10_base() {
    step "リポジトリを日本ミラーへ変更"
    # 24.04 以降は deb822 形式の ubuntu.sources を用いる。両形式に対応する。
    # security.ubuntu.com は同期遅延によるセキュリティ更新の遅れを避けるため変更しない。
    local f
    for f in /etc/apt/sources.list /etc/apt/sources.list.d/ubuntu.sources; do
        [ -f "$f" ] || continue
        backup_once "$f"
        sudo sed -i -E 's|//([a-z]{2}\.)?archive\.ubuntu\.com|//jp.archive.ubuntu.com|g' "$f" \
            || record_failure "ミラー変更: $f"
    done

    step "システム全体の更新"
    apt_update  || record_fatal "apt-get update に失敗(以降のすべての導入が失敗する)"
    apt_upgrade || record_fatal "apt-get dist-upgrade に失敗(基盤が更新されていない)"
    apt_fix     || record_fatal "apt-get -f install に失敗(依存関係が破損している)"

    step "基本ツールの導入"
    apt_install \
        ca-certificates curl wget gnupg dirmngr lsb-release software-properties-common \
        locales tzdata net-tools pciutils \
        build-essential git subversion emacs \
        p7zip-full unzip zip xz-utils rsync jq tree htop lshw sysstat \
        dpkg-dev gdebi-core gnome-disk-utility usb-creator-gtk cifs-utils sshfs \
        nodejs npm poppler-utils html2text trash-cli deja-dup duplicity

    step "タイムゾーンと NTP 時刻同期"
    sudo timedatectl set-timezone Asia/Tokyo || record_failure "timedatectl set-timezone Asia/Tokyo"
    sudo timedatectl set-ntp true            || record_failure "timedatectl set-ntp true"
    if unit_exists systemd-timesyncd.service; then
        sudo install -d -m 0755 /etc/systemd/timesyncd.conf.d
        sudo tee /etc/systemd/timesyncd.conf.d/50-jp.conf >/dev/null <<'EOF'
[Time]
NTP=ntp.nict.jp
FallbackNTP=ntp.ubuntu.com
EOF
        sudo chmod 644 /etc/systemd/timesyncd.conf.d/50-jp.conf
        sudo systemctl restart systemd-timesyncd.service || record_failure "systemd-timesyncd の再起動"
    fi
    timedatectl status

    step "ロケール(日本語 UTF-8)"
    # B30 の check-language-support が現在のロケールをもとに判定するため,ここで確定させる。
    if [ -f /etc/locale.gen ]; then
        backup_once /etc/locale.gen
        sudo sed -i -E 's/^#[[:space:]]*(ja_JP\.UTF-8[[:space:]]+UTF-8)/\1/' /etc/locale.gen \
            || record_failure "/etc/locale.gen の書換"
    fi
    sudo locale-gen || record_failure "locale-gen"
    # LC_ALL は恒久設定しない(コマンド単位の一時切替を妨げるため)
    sudo update-locale LANG=ja_JP.UTF-8 LANGUAGE="ja:en" || record_failure "update-locale"
    record_manual "ロケールの変更は再ログインまたは再起動で反映される。LC_ALL は恒久設定していない。"

    step "不要パッケージと旧カーネルの整理"
    # 旧カーネルは autoremove の保持規則(稼働中と最新を残す)に従って削除する。
    apt_autoremove || record_failure "autoremove"
    apt_autoclean  || record_failure "autoclean"
    info "稼働中のカーネル: $(uname -r)"
    info "導入済みカーネル:"
    dpkg-query -W -f='   ${Package}\n' 'linux-image-[0-9]*' 2>/dev/null
}

# ============================================================
# B20 リポジトリ登録
# ============================================================
block_20_repositories() {
    step "Google Chrome"
    add_repo "Google Chrome" \
        "https://dl.google.com/linux/linux_signing_key.pub" \
        "/etc/apt/keyrings/google-chrome.gpg" \
        "https://dl.google.com/linux/chrome/deb/dists/stable/Release" \
        "deb [arch=amd64 signed-by=/etc/apt/keyrings/google-chrome.gpg] https://dl.google.com/linux/chrome/deb/ stable main" \
        "google-chrome.list"

    step "CRAN (R 4 系)"
    # jammy / noble / resolute のいずれも <codename>-cran40 で提供される。
    add_repo "CRAN" \
        "https://cloud.r-project.org/bin/linux/ubuntu/marutter_pubkey.asc" \
        "/etc/apt/keyrings/cran.gpg" \
        "https://cloud.r-project.org/bin/linux/ubuntu/${OS_CODENAME}-cran40/Release" \
        "deb [signed-by=/etc/apt/keyrings/cran.gpg] https://cloud.r-project.org/bin/linux/ubuntu ${OS_CODENAME}-cran40/" \
        "cran.list"

    step "Visual Studio Code"
    add_repo "Visual Studio Code" \
        "https://packages.microsoft.com/keys/microsoft.asc" \
        "/etc/apt/keyrings/microsoft.gpg" \
        "https://packages.microsoft.com/repos/code/dists/stable/Release" \
        "deb [arch=amd64,arm64,armhf signed-by=/etc/apt/keyrings/microsoft.gpg] https://packages.microsoft.com/repos/code stable main" \
        "vscode.list"

    step "Sublime Text"
    add_repo "Sublime Text" \
        "https://download.sublimetext.com/sublimehq-pub.gpg" \
        "/etc/apt/keyrings/sublimehq.gpg" \
        "https://download.sublimetext.com/apt/stable/Release" \
        "deb [signed-by=/etc/apt/keyrings/sublimehq.gpg] https://download.sublimetext.com/ apt/stable/" \
        "sublime-text.list"

    step "DBeaver Community Edition"
    add_repo "DBeaver CE" \
        "https://dbeaver.io/debs/dbeaver.gpg.key" \
        "/etc/apt/keyrings/dbeaver.gpg" \
        "https://dbeaver.io/debs/dbeaver-ce/Release" \
        "deb [signed-by=/etc/apt/keyrings/dbeaver.gpg] https://dbeaver.io/debs/dbeaver-ce /" \
        "dbeaver.list"

    step "pgAdmin 4"
    add_repo "pgAdmin 4" \
        "https://www.pgadmin.org/static/packages_pgadmin_org.pub" \
        "/etc/apt/keyrings/pgadmin.gpg" \
        "https://ftp.postgresql.org/pub/pgadmin/pgadmin4/apt/${OS_CODENAME}/dists/pgadmin4/Release" \
        "deb [signed-by=/etc/apt/keyrings/pgadmin.gpg] https://ftp.postgresql.org/pub/pgadmin/pgadmin4/apt/${OS_CODENAME} pgadmin4 main" \
        "pgadmin4.list"

    step "MongoDB"
    add_repo "MongoDB 8.0" \
        "https://www.mongodb.org/static/pgp/server-8.0.asc" \
        "/etc/apt/keyrings/mongodb-server-8.0.gpg" \
        "https://repo.mongodb.org/apt/ubuntu/dists/${OS_CODENAME}/mongodb-org/8.0/Release" \
        "deb [arch=amd64,arm64 signed-by=/etc/apt/keyrings/mongodb-server-8.0.gpg] https://repo.mongodb.org/apt/ubuntu ${OS_CODENAME}/mongodb-org/8.0 multiverse" \
        "mongodb-org-8.0.list"

    step "NVIDIA CUDA"
    if [ "${HAS_NVIDIA}" -eq 1 ] && [ "$(uname -m)" = "x86_64" ]; then
        local cuda_distro="ubuntu${OS_VERSION_ID//./}" cuda_deb="/tmp/cuda-keyring.deb"
        if fetch "https://developer.download.nvidia.com/compute/cuda/repos/${cuda_distro}/x86_64/cuda-keyring_1.1-1_all.deb" "${cuda_deb}"; then
            apt_install_deb "${cuda_deb}" || record_failure "cuda-keyring の導入"
            rm -f "${cuda_deb}"
        else
            record_failure "cuda-keyring の取得(${cuda_distro} 向けリポジトリの提供状況とファイル名を NVIDIA CUDA Downloads で確認すること)"
        fi
    else
        info "NVIDIA GPU(x86_64)を検出しないため CUDA リポジトリは登録しない"
    fi

    step "リポジトリ情報の更新"
    apt_update || record_fatal "apt-get update に失敗(登録したリポジトリ定義と公開鍵を確認すること。B30 の導入がすべて失敗する)"
}

# ============================================================
# B30 アプリ導入(apt)
# ============================================================
block_30_apps_apt() {
    step "パッケージ情報の更新"
    apt_update || record_fatal "apt-get update に失敗(以降の導入がすべて失敗する)"

    step "コンパイラ・ビルド基盤"
    apt_install gcc g++ gfortran cmake make pkg-config ninja-build ccache \
        autoconf automake libtool bison flex swig scons llvm \
        libc6-dev libncurses-dev libreadline-dev libffi-dev libssl-dev \
        zlib1g-dev libbz2-dev liblzma-dev libyaml-dev libpcre2-dev \
        libglib2.0-dev libxml2 libxml2-dev libxslt1-dev libxmlsec1-dev \
        libusb-1.0-0-dev libpcap-dev tk-dev mpi-default-dev

    step "Python(システム版)"
    apt_install python3 python3-dev python3-pip python3-venv python3-setuptools python3-tk

    step "Java"
    apt_install default-jdk default-jre

    step "データベース(PostgreSQL)"
    apt_install postgresql postgresql-client postgresql-contrib libpq-dev
    local pgver=""
    command -v psql >/dev/null 2>&1 && pgver="$(psql --version | awk '{print $3}' | cut -d. -f1)"
    if [ -n "${pgver}" ]; then
        apt_install "postgresql-server-dev-${pgver}" \
            postgis "postgresql-${pgver}-postgis-3" "postgresql-plpython3-${pgver}" \
            python3-psycopg2 libpostgresql-jdbc-java
        record_manual "PostgreSQL は peer 認証で 'sudo -u postgres psql' から利用する。空間拡張は 'CREATE EXTENSION postgis;',PL/Python は 'CREATE EXTENSION plpython3u;' で有効化する。"
    else
        record_failure "PostgreSQL のメジャーバージョンを判定できず,拡張パッケージを導入しない"
    fi
    repo_registered pgadmin4.list && apt_install pgadmin4-desktop

    step "データベース(SQLite・Redis・MongoDB)"
    apt_install sqlite3 libsqlite3-dev sqlitebrowser redis-server redis-tools
    if repo_registered mongodb-org-8.0.list; then
        apt_install mongodb-org
    else
        info "MongoDB のリポジトリが登録されていないため導入しない(B20 の確認事項を参照)"
    fi
    repo_registered dbeaver.list && apt_install dbeaver-ce
    record_manual "SQLite の GUI は apt で管理できる sqlitebrowser を導入した。SQLiteStudio は公式サイトの書庫のみの配布であり自動導入しない。SQL Workbench/J も zip 配布のため自動導入しない。いずれもガイドの手順で必要時に導入すること。"

    step "科学技術計算"
    apt_install liblapack-dev libopenblas-dev libgsl-dev libfftw3-dev \
        libglpk-dev glpk-utils libsuitesparse-dev libarpack2-dev libqhull-dev \
        libeigen3-dev gnuplot graphviz libgraphviz-dev
    record_manual "Intel oneAPI MKL は容量が大きく専用リポジトリを要するため本スクリプトでは導入しない。必要な場合はガイド記載の Intel 公式手順でリポジトリを追加し 'sudo apt-get install intel-oneapi-mkl-devel' を実行すること。"

    step "R"
    apt_install r-base r-base-dev r-recommended r-mathlib littler libcurl4-openssl-dev

    step "マルチメディア・画像処理"
    apt_install ffmpeg libavcodec-dev libavformat-dev libavutil-dev libswscale-dev \
        libavdevice-dev \
        libx264-dev libxvidcore-dev libjpeg-dev libpng-dev libtiff-dev liblcms2-dev \
        libfreetype6-dev libopenexr-dev libva-dev libv4l-dev v4l-utils libjack-jackd2-dev \
        vlc audacity obs-studio openshot-qt kdenlive guvcview \
        gimp libbabl-dev libgegl-dev inkscape pinta blender meshlab \
        imagemagick libmagickcore-dev libmagickwand-dev graphicsmagick librsvg2-bin
    apt_install libopencv-dev python3-opencv libgtk-3-dev libtbb-dev libpcl-dev

    step "空間データ処理(GDAL / PostGIS / SpatiaLite / QGIS / GRASS)"
    apt_install libgeos-dev libgeos++-dev libproj-dev proj-bin proj-data \
        libspatialite-dev spatialite-bin libsqlite3-mod-spatialite \
        libgdal-dev gdal-bin python3-gdal libfreexl-dev libgeotiff-dev libhpdf-dev libreadosm-dev
    apt_install qgis qgis-plugin-grass grass

    step "ネットワーク・診断・リモート接続"
    apt_install nmap tcpdump wireshark clamav clamav-daemon clamtk lynis \
        netcat-openbsd intel-gpu-tools openssh-server filezilla \
        remmina remmina-plugin-rdp remmina-plugin-vnc xrdp
    if pkg_installed xrdp; then
        sudo adduser xrdp ssl-cert >/dev/null 2>&1 || record_failure "xrdp を ssl-cert グループへ追加"
        record_manual "xrdp は導入するが ufw が 3389/tcp を遮断しているため既定では接続できない。利用する場合のみ 'sudo ufw allow 3389/tcp' を実行すること。"
    fi
    record_manual "Wireshark を一般利用者で使う場合は 'sudo dpkg-reconfigure wireshark-common' で非 root 捕捉を許可し,'sudo usermod -aG wireshark ${TARGET_USER}' を実行して再ログインすること。ClamAV の定義更新は 'sudo freshclam' である。"

    step "ユーティリティ"
    apt_install plocate catfish inotify-tools flameshot gparted synaptic
    sudo updatedb || record_failure "updatedb"
    case "${OS_CODENAME}" in
        resolute) apt_install hardinfo2 ;;
        *)        apt_install hardinfo  ;;
    esac
    if [ "${IS_LUBUNTU}" -eq 0 ]; then
        apt_install gnome-tweaks
        case "${OS_CODENAME}" in
            jammy) apt_install gedit ;;
            *)     apt_install gnome-text-editor ;;
        esac
    fi
    # 26.04 では「ソフトウェアとアップデート」が既定で導入されない
    case "${OS_CODENAME}" in
        resolute) apt_install software-properties-gtk ;;
    esac

    step "エディタ・統合開発環境"
    apt_install geany geany-plugins
    repo_registered vscode.list       && apt_install code
    repo_registered sublime-text.list && apt_install sublime-text
    record_manual "教育用 Java 環境の BlueJ と Greenfoot は,配布 deb のファイル名がバージョンごとに変わるため自動導入しない。必要時に各公式サイトから deb を取得し 'sudo apt install ./<ファイル名>.deb' で導入すること。"

    step "ゲーム"
    apt_install 0ad supertuxkart wesnoth gnome-mahjongg extremetuxracer \
        aisleriot warzone2100 hedgewars frozen-bubble
    record_manual "Steam / Lutris / Heroic Games Launcher はアカウント登録・ライセンス同意・追加アーキテクチャ(i386)の有効化を伴うため自動導入しない。手順はガイドのゲーム章に記載してある。"

    step "グラフィックドライバ(NVIDIA)"
    if [ "${HAS_NVIDIA}" -eq 1 ]; then
        apt_install ubuntu-drivers-common
        sudo ubuntu-drivers install || record_failure "ubuntu-drivers install"
        record_manual "NVIDIA ドライバは導入後の再起動で有効になる。セキュアブート有効時は再起動時の MOK Manager で登録を完了させること。反映後 'nvidia-smi' で確認する。"
    else
        info "NVIDIA GPU を検出しないため専用ドライバは導入しない"
    fi

    step "NVIDIA CUDA Toolkit / cuDNN"
    if [ "${HAS_NVIDIA}" -eq 1 ] && pkg_installed cuda-keyring; then
        local cuda_pkg cuda_major
        cuda_pkg="$(apt-cache search --names-only '^cuda-toolkit-[0-9]+-[0-9]+$' 2>/dev/null \
                    | awk '{print $1}' | sort -V | tail -1)"
        if [ -n "${cuda_pkg}" ]; then
            apt_install "${cuda_pkg}"
            cuda_major="$(echo "${cuda_pkg}" | sed -E 's/^cuda-toolkit-([0-9]+)-[0-9]+$/\1/')"
            apt_install "cudnn9-cuda-${cuda_major}"
            record_manual "CUDA Toolkit は ${cuda_pkg},cuDNN は cudnn9-cuda-${cuda_major} を導入した。PyTorch・TensorFlow のホイールは CUDA ランタイムを同梱するため,この Toolkit は主に nvcc を用いる開発向けである。"
        else
            record_failure "cuda-toolkit パッケージが見つからない(CUDA リポジトリの登録を確認すること)"
        fi
    else
        info "NVIDIA GPU または CUDA リポジトリが無いため CUDA Toolkit は導入しない"
    fi

    step "日本語環境"
    apt_install language-pack-ja language-pack-gnome-ja manpages-ja \
        language-selector-common \
        fonts-noto-cjk fonts-noto-cjk-extra fonts-ipafont fonts-vlgothic fonts-takao \
        nkf mecab libmecab-dev mecab-ipadic-utf8 python3-mecab
    # 現在のロケール(B10 で設定済み)に対して不足する言語サポートを補う
    if command -v check-language-support >/dev/null 2>&1; then
        local lang_pkgs
        lang_pkgs="$(check-language-support)"
        if [ -n "${lang_pkgs}" ]; then
            apt_install ${lang_pkgs}
        else
            info "check-language-support: 追加すべきパッケージはない"
        fi
    else
        record_failure "check-language-support が見つからない(language-selector-common の導入を確認すること)"
    fi
    if [ "${IS_LUBUNTU}" -eq 1 ]; then
        apt_install fcitx5 fcitx5-mozc mozc-utils-gui
    else
        apt_install ibus ibus-mozc mozc-utils-gui
    fi
    record_manual "Emacs は B70 の別名で X の入力メソッドを無効化している(XMODIFIERS=@im=none)。Emacs 内で Mozc を使う場合は emacs-mozc を導入し,.bashrc の当該別名を削除すること。"

    step "デスクトップ関連"
    repo_registered google-chrome.list && apt_install google-chrome-stable
    apt_install papirus-icon-theme xdg-user-dirs-gtk
    if [ "${IS_LUBUNTU}" -eq 1 ]; then
        apt_install pcmanfm-qt lxqt-config lxqt-themes qterminal featherpad
    fi
}

# ============================================================
# B40 アプリ導入(ソース・外部配布物)
# ============================================================
block_40_apps_source() {
    step "pyenv"
    export PYENV_ROOT="${HOME}/.pyenv"
    export PATH="${PYENV_ROOT}/bin:${PATH}"
    if [ -d "${PYENV_ROOT}/.git" ]; then
        git -C "${PYENV_ROOT}" pull --ff-only || record_failure "pyenv の更新"
    else
        git clone https://github.com/pyenv/pyenv.git "${PYENV_ROOT}" \
            || record_fatal "pyenv の取得に失敗(B70 が前提とする Python 環境を構築できない)"
    fi
    if [ ! -d "${PYENV_ROOT}/plugins/pyenv-virtualenv" ]; then
        git clone https://github.com/pyenv/pyenv-virtualenv.git \
            "${PYENV_ROOT}/plugins/pyenv-virtualenv" || record_failure "pyenv-virtualenv の取得"
    fi

    step "Python 本体のビルド"
    CONFIGURE_OPTS="--enable-shared" pyenv install -s "${PY_MAIN_VERSION}" \
        || record_fatal "Python ${PY_MAIN_VERSION} のビルドに失敗(B70 が前提とする Python 環境を構築できない)"
    CONFIGURE_OPTS="--enable-shared" pyenv install -s "${PY_MP_VERSION}" \
        || record_failure "Python ${PY_MP_VERSION} のビルド(MediaPipe 用)"
    pyenv global "${PY_MAIN_VERSION}" || record_failure "pyenv global ${PY_MAIN_VERSION}"
    eval "$(pyenv init -)"

    step "仮想環境の作成"
    # システム Python は PEP 668 により pip 導入を拒否するため,用途別の仮想環境を用いる。
    venv_create "${VENV_MAIN}" "${PY_MAIN_VERSION}" \
        || record_fatal "主仮想環境 ${VENV_MAIN} を作成できない(以降の Python パッケージ導入がすべて失敗する)"
    venv_create "${VENV_MP}" "${PY_MP_VERSION}" \
        || record_failure "MediaPipe 用仮想環境 ${VENV_MP} を作成できない"

    step "Python パッケージ(主環境)"
    venv_pip_install "${VENV_MAIN}" pip setuptools wheel
    venv_pip_install "${VENV_MAIN}" \
        numpy scipy matplotlib pandas ipython jupyterlab notebook ipykernel spyder \
        pillow scikit-image scikit-learn statsmodels seaborn sympy h5py plotly \
        requests lxml beautifulsoup4 openpyxl xlsxwriter numexpr bottleneck sphinx \
        opencv-python opencv-contrib-python dlib psycopg2-binary \
        torch torchvision torchaudio insightface
    # 推論基盤は GPU の有無で導入物が変わる(環境条件による分岐)
    if [ "${HAS_NVIDIA}" -eq 1 ]; then
        venv_pip_install "${VENV_MAIN}" onnxruntime-gpu "tensorflow[and-cuda]"
    else
        venv_pip_install "${VENV_MAIN}" onnxruntime tensorflow
    fi
    # GDAL バインディングはシステムの GDAL に版を合わせる
    if command -v gdal-config >/dev/null 2>&1; then
        venv_pip_install "${VENV_MAIN}" "GDAL==$(gdal-config --version)" \
            fiona geopandas shapely pyproj rtree folium
    else
        record_failure "gdal-config が無く GDAL の Python バインディングを導入しない"
    fi
    "${VENV_MAIN}/bin/python" -m ipykernel install --user \
        --name=myproject_env --display-name="Python (myproject_env)" \
        || record_failure "ipykernel の登録(myproject_env)"
    record_manual "PyTorch は PyPI 既定のホイールを導入した。特定の CUDA 版に合わせる場合は pytorch.org の Get Started で示される --index-url 付きコマンドで入れ直すこと。onnxruntime-gpu も既定ビルドが対象とする CUDA 版が変わるため,公式のインストールページを確認すること。"

    step "InsightFace の学習済みモデル"
    if [ -x "${VENV_MAIN}/bin/python" ]; then
        "${VENV_MAIN}/bin/python" - <<'EOF' || record_failure "InsightFace モデル(buffalo_l)の取得"
from insightface.app import FaceAnalysis
app = FaceAnalysis(name="buffalo_l", providers=["CPUExecutionProvider"])
app.prepare(ctx_id=-1, det_size=(640, 640))
EOF
    fi
    record_manual "InsightFace の学習済みモデル(buffalo_l 等)は非商用の研究目的に限定される。商用利用では自前で学習したモデルか別途ライセンスを得たモデルを ~/.insightface/models/ に配置すること。"

    step "MediaPipe(専用仮想環境)"
    if [ -x "${VENV_MP}/bin/pip" ]; then
        venv_pip_install "${VENV_MP}" pip setuptools wheel
        venv_pip_install "${VENV_MP}" mediapipe opencv-python numpy
        mkdir -p "${HOME}/mediapipe_models"
        if [ ! -f "${HOME}/mediapipe_models/face_landmarker.task" ]; then
            fetch "https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task" \
                  "${HOME}/mediapipe_models/face_landmarker.task" \
                || record_failure "MediaPipe Face Landmarker モデルの取得"
        fi
        record_manual "MediaPipe は Python ${PY_MP_VERSION} の仮想環境 ${VENV_MP} に導入した(新しい Python 向けホイールが未提供のため)。他タスクのモデルは MediaPipe Solutions の各ドキュメントから取得すること。"
    fi

    step "dlib (C++)"
    local dlib_src="/usr/local/src/dlib"
    sudo install -d -m 0755 /usr/local/src
    if [ -d "${dlib_src}/.git" ]; then
        sudo git -C "${dlib_src}" pull --ff-only || record_failure "dlib の更新"
    else
        sudo git clone --depth 1 https://github.com/davisking/dlib.git "${dlib_src}" \
            || record_failure "dlib の取得"
    fi
    if [ -d "${dlib_src}" ]; then
        (
            sudo cmake -S "${dlib_src}" -B "${dlib_src}/build" \
                 -DCMAKE_BUILD_TYPE=Release -DBUILD_SHARED_LIBS=ON \
            && sudo cmake --build "${dlib_src}/build" -j"$(nproc)" \
            && sudo cmake --install "${dlib_src}/build"
        ) || record_failure "dlib のビルド"
        sudo ldconfig
    fi

    step "Eclipse IDE"
    local eclipse_rel="2026-06" eclipse_dir="/usr/local/eclipse" eclipse_tar="/tmp/eclipse-jee.tar.gz"
    if [ ! -x "${eclipse_dir}/eclipse" ]; then
        if fetch "https://download.eclipse.org/technology/epp/downloads/release/${eclipse_rel}/R/eclipse-jee-${eclipse_rel}-R-linux-gtk-x86_64.tar.gz" \
                 "${eclipse_tar}"; then
            sudo rm -rf "${eclipse_dir}"
            (cd /usr/local && sudo tar -xzf "${eclipse_tar}") || record_failure "Eclipse の展開"
            sudo ln -sf "${eclipse_dir}/eclipse" /usr/local/bin/eclipse
            rm -f "${eclipse_tar}"
        else
            record_failure "Eclipse の取得(公開中のリリース名を eclipse.org で確認すること)"
        fi
    fi

    step "RStudio Desktop"
    local rstudio_ver="2026.07.1-147" rstudio_deb="/tmp/rstudio.deb"
    if ! command -v rstudio >/dev/null 2>&1; then
        if fetch "https://download1.rstudio.org/electron/jammy/amd64/rstudio-${rstudio_ver}-amd64.deb" \
                 "${rstudio_deb}"; then
            apt_install_deb "${rstudio_deb}" || record_failure "RStudio の導入"
            rm -f "${rstudio_deb}"
        else
            record_failure "RStudio の取得(posit.co で現行版のファイル名を確認すること)"
        fi
    fi
    record_manual "RStudio は jammy 版が配布され,公式対応は Ubuntu 22 / 24 である。26.04 では動作確認を行うこと。"

    step "diagrams.net Desktop"
    local drawio_deb="/tmp/drawio.deb" drawio_url=""
    if ! command -v drawio >/dev/null 2>&1; then
        drawio_url="$(curl -fsSL --retry 3 https://api.github.com/repos/jgraph/drawio-desktop/releases/latest 2>/dev/null \
                      | jq -r '.assets[].browser_download_url | select(test("amd64\\.deb$"))' | head -1)"
        if [ -n "${drawio_url}" ] && fetch "${drawio_url}" "${drawio_deb}"; then
            apt_install_deb "${drawio_deb}" || record_failure "diagrams.net Desktop の導入"
            rm -f "${drawio_deb}"
        else
            record_failure "diagrams.net Desktop の取得"
        fi
    fi

    step "Google Earth Pro"
    local ge_deb="/tmp/google-earth-pro.deb"
    if ! command -v google-earth-pro >/dev/null 2>&1; then
        if fetch "https://dl.google.com/dl/earth/client/current/google-earth-pro-stable_current_amd64.deb" "${ge_deb}"; then
            apt_install_deb "${ge_deb}" || record_failure "Google Earth Pro の導入"
            rm -f "${ge_deb}"
            record_manual "Google Earth Pro の deb は導入時に Google 独自の APT リポジトリを /etc/apt/sources.list.d/ に登録する。以降の更新は apt で行われる。"
        else
            record_failure "Google Earth Pro の取得(google.com/earth で配布状況を確認すること)"
        fi
    fi

    step "snap パッケージ"
    snap_install firefox chromium cloudcompare

    step "自動導入しないアプリの案内"
    record_manual "Unreal Engine は Epic Games / GitHub アカウントの連携とライセンス同意が必要で,取得とビルドに数十 GB と長時間を要するため本スクリプトでは導入しない。手順はガイドのゲーム章に記載してある。"
    record_manual "MakeHuman は公式 PPA が 24.04(noble) までの提供であり 26.04 向けが無いため導入しない。必要時はガイドの手順(PPA またはソース実行,Blender アドオン MPFB)に従うこと。"
}

# ============================================================
# B50 不要パッケージ削除
# ============================================================
block_50_remove_packages() {
    step "利用しないゲーム類"
    # B30 で導入するゲームは対象外とする。方針を変える場合は当該行を編集すること。
    apt_purge gnome-sudoku gnome-mines gnome-taquin \
        gnome-tetravex gnome-nibbles gnome-robots gnome-chess gnome-klotski \
        quadrapassel four-in-a-row hitori lightsoff swell-foop iagno \
        five-or-more tali supertux2 openttd \
        gcompris tuxpaint tuxmath tuxtype \
        blinken gbrainy kanagram khangman ktuberling
    record_manual "ゲームは 0ad / supertuxkart / wesnoth / gnome-mahjongg / extremetuxracer / aisleriot / warzone2100 / hedgewars / frozen-bubble を残し,それ以外は削除する。方針を変える場合は B30 の導入行と B50 の削除行を編集すること。"

    step "プリインストールアプリ(オフィス・メール)"
    apt_purge_glob 'libreoffice*' 'thunderbird*'
    record_manual "LibreOffice と Thunderbird は削除する方針である。必要な環境では B50 の当該行を削除してから実行すること。"

    step "常駐サーバ類"
    apt_purge apache2 apache2-utils apache2-bin apache2-data \
        postfix munin munin-common munin-node

    step "その他の不要パッケージ"
    apt_purge wine winetricks \
        virtualbox-guest-utils virtualbox-guest-x11 \
        ubiquity ubiquity-frontend-gtk ubiquity-frontend-debconf ubiquity-ubuntu-artwork \
        ubiquity-slideshow-ubuntu ubiquity-slideshow-lubuntu
    record_manual "Wine は削除する方針である。Steam(Proton) や Lutris で Windows 用ゲームを動かす場合は Wine 系パッケージが必要になるため,B50 の当該行を削除してから実行すること。"

    step "整理"
    apt_autoremove || record_failure "autoremove"
    apt_autoclean  || record_failure "autoclean"
    apt_fix        || record_failure "apt-get -f install"
}

# ============================================================
# B60 システム設定・不要サービス停止
# ============================================================
block_60_system() {
    step "不要サービスの停止と無効化"
    service_disable apport.service whoopsie.service kerneloops.service

    step "クラッシュレポートの無効化"
    if [ -f /etc/default/apport ]; then
        backup_once /etc/default/apport
        sudo sed -i 's/^enabled=1/enabled=0/' /etc/default/apport \
            || record_failure "/etc/default/apport の書換"
    fi

    step "SSH の root ログイン禁止"
    if [ -f /etc/ssh/sshd_config ]; then
        sudo install -d -m 0755 /etc/ssh/sshd_config.d
        printf 'PermitRootLogin no\n' | sudo tee /etc/ssh/sshd_config.d/50-hardening.conf >/dev/null
        # 24.04 以降は socket 起動方式のため socket 側も対象とする。
        # 停止中でも失敗しないよう try-reload-or-restart を用いる。
        local u
        for u in ssh.socket ssh.service; do
            unit_exists "$u" || continue
            sudo systemctl try-reload-or-restart "$u" || record_failure "SSH の再読込: $u"
        done
        record_manual "SSH は 22/tcp のまま運用する。ポートを変更する場合は sshd_config の Port を変更し,'systemctl daemon-reload; systemctl restart ssh.socket ssh' を実行し,ufw で新ポートを許可して 22 の許可を削除する。反映は 'ss -tlnp' で確認する。"
        record_manual "SSH 経由で設定を変更した場合は,現在のセッションを維持したまま別端末で接続を確認すること。"
    fi

    step "ufw ファイアウォール"
    apt_install ufw
    sudo ufw default deny incoming  || record_failure "ufw default deny incoming"
    sudo ufw default allow outgoing || record_failure "ufw default allow outgoing"
    sudo ufw allow 22/tcp           || record_failure "ufw allow 22/tcp"
    sudo ufw --force enable         || record_failure "ufw の有効化"
    sudo ufw status verbose

    step "キーボード(Caps Lock を Ctrl 化)"
    if [ -f /etc/default/keyboard ]; then
        backup_once /etc/default/keyboard
        if grep -q 'ctrl:nocaps' /etc/default/keyboard; then
            info "XKBOPTIONS は設定済みである"
        else
            local cur new
            cur="$( . /etc/default/keyboard; echo "${XKBOPTIONS:-}" )"
            new="${cur:+${cur},}ctrl:nocaps"
            if grep -q '^XKBOPTIONS=' /etc/default/keyboard; then
                sudo sed -i -E "s|^XKBOPTIONS=.*|XKBOPTIONS=\"${new}\"|" /etc/default/keyboard \
                    || record_failure "/etc/default/keyboard の書換"
            else
                echo "XKBOPTIONS=\"${new}\"" | sudo tee -a /etc/default/keyboard >/dev/null \
                    || record_failure "/etc/default/keyboard への追記"
            fi
        fi
        record_manual "Caps Lock の Ctrl 化(ctrl:nocaps)は再ログインまたは再起動で反映される。無効化したい場合は /etc/default/keyboard の XKBOPTIONS を caps:none に書き換える。"
    fi

    step "Java の既定実装と JAVA_HOME"
    # default-jdk が指す版は 26.04 が OpenJDK 25,24.04 が 21,22.04 が 11 である。
    local d java_home=""
    for d in /usr/lib/jvm/java-25-openjdk-amd64 \
             /usr/lib/jvm/java-21-openjdk-amd64 \
             /usr/lib/jvm/java-17-openjdk-amd64 \
             /usr/lib/jvm/java-11-openjdk-amd64 \
             /usr/lib/jvm/default-java; do
        [ -d "$d" ] && { java_home="$d"; break; }
    done
    if [ -n "${java_home}" ]; then
        sudo tee /etc/profile.d/java.sh >/dev/null <<EOF
export JAVA_HOME=${java_home}
export PATH=\${JAVA_HOME}/bin:\${PATH}
EOF
        sudo chmod 644 /etc/profile.d/java.sh
        sudo update-alternatives --auto java || record_failure "update-alternatives --auto java"
        record_manual "JAVA_HOME は次回ログインから有効になる。複数の JDK を使い分ける場合は 'sudo update-alternatives --config java' を用いる。"
    else
        record_failure "JVM が見つからず JAVA_HOME を設定できない"
    fi

    step "CUDA の環境変数"
    if [ -d /usr/local/cuda ]; then
        sudo tee /etc/profile.d/cuda.sh >/dev/null <<'EOF'
export CUDA_HOME=/usr/local/cuda
export PATH=${CUDA_HOME}/bin:${PATH}
export LD_LIBRARY_PATH=${CUDA_HOME}/lib64:${LD_LIBRARY_PATH:-}
EOF
        sudo chmod 644 /etc/profile.d/cuda.sh
        record_manual "CUDA_HOME / PATH / LD_LIBRARY_PATH は次回ログインから有効になる。'nvcc --version' で確認する。"
    else
        info "/usr/local/cuda が無いため CUDA の環境変数は設定しない"
    fi

    step "R の Java 連携再設定"
    sudo R CMD javareconf || record_failure "R CMD javareconf"

    step "X11 セッションでの画面ブランキング抑止"
    if [ -d /etc/X11/Xsession.d ]; then
        sudo tee /etc/X11/Xsession.d/98x11-noblank >/dev/null <<'EOF'
xset s off
xset s noblank
xset -dpms
EOF
        sudo chmod 644 /etc/X11/Xsession.d/98x11-noblank
    fi
    record_manual "Wayland セッションでは xset が効かない。B70 の gsettings 設定で抑止する。セッション種別は 'echo \$XDG_SESSION_TYPE' で確認できる。"

    step "LightDM のゲストアカウント無効化"
    if [ -d /etc/lightdm ]; then
        sudo install -d -m 0755 /etc/lightdm/lightdm.conf.d
        printf '[Seat:*]\nallow-guest=false\n' \
            | sudo tee /etc/lightdm/lightdm.conf.d/50-no-guest.conf >/dev/null \
            || record_failure "LightDM 設定の書込"
    fi
}

# ============================================================
# B70 ユーザ設定
#     設定ファイルは外部から取得せず,本ブロック内に埋め込む。
# ============================================================
gset() {   # gset <schema> <key> <value>  スキーマが無い環境(LXQt 等)は対象外
    gsettings list-schemas 2>/dev/null | grep -qx "$1" || { info "スキーマ非該当のため設定しない: $1"; return 0; }
    gsettings set "$1" "$2" "$3" || record_failure "gsettings set $1 $2"
}

xfq() {    # xfq <プロパティ> <型> <値>  XFCE が無い環境は対象外
    command -v xfconf-query >/dev/null 2>&1 || return 0
    xfconf-query -c xfce4-power-manager -p "$1" -s "$3" 2>/dev/null \
        || xfconf-query -c xfce4-power-manager -p "$1" -n -t "$2" -s "$3" \
        || record_failure "xfconf-query $1"
}

block_70_user() {
    step "デスクトップ設定(gsettings)"
    gset org.gnome.desktop.session               idle-delay                "uint32 0"
    gset org.gnome.desktop.screensaver           lock-enabled              false
    gset org.gnome.settings-daemon.plugins.power sleep-inactive-ac-type    "'nothing'"
    gset org.gnome.nautilus.preferences          always-use-location-entry true
    gset org.gnome.shell.extensions.ding         show-home                 true
    gset org.gnome.shell.extensions.ding         show-trash                true
    if [ "${IS_LUBUNTU}" -eq 1 ]; then
        record_manual "Lubuntu(LXQt) の日本語入力は fcitx5 設定ツールで Mozc を追加すること。"
    else
        gset org.gnome.desktop.input-sources sources "[('xkb', 'jp'), ('ibus', 'mozc-jp')]"
        record_manual "Wayland で変換候補の表示位置がずれる場合は fcitx5-mozc へ切り替え,「地域と言語」で IM システムを Fcitx 5 にすること。"
    fi

    step "電源管理(XFCE 環境のみ)"
    xfq /xfce4-power-manager/dpms-enabled                bool false
    xfq /xfce4-power-manager/inactivity-sleep-mode-on-ac uint 0

    step "ユーザディレクトリ名の英語化"
    LANG=C LC_ALL=C xdg-user-dirs-gtk-update --force \
        || record_failure "xdg-user-dirs-gtk-update"
    record_manual "旧名称の日本語ディレクトリが空でない場合,中身は自動では移動されない。人手で移動してから旧ディレクトリを削除すること。"

    step "Emacs の設定"
    backup_once "${HOME}/.emacs"
    cat > "${HOME}/.emacs" <<'EOF'
;; 日本語環境
(set-language-environment "Japanese")
(prefer-coding-system 'utf-8)
;; 表示
(setq inhibit-startup-screen t)
(tool-bar-mode -1)
(column-number-mode 1)
(show-paren-mode 1)
;; 編集
(setq make-backup-files nil)
(setq-default indent-tabs-mode nil)
(setq-default tab-width 4)
(global-set-key (kbd "C-h") 'delete-backward-char)
EOF

    step "X リソースの設定"
    backup_once "${HOME}/.Xresources"
    cat > "${HOME}/.Xresources" <<'EOF'
XTerm*faceName: DejaVu Sans Mono
XTerm*faceSize: 12
XTerm*saveLines: 10000
XTerm*locale: true
XTerm*metaSendsEscape: true
EOF

    step ".bashrc の管理ブロック更新"
    local bashrc="${HOME}/.bashrc"
    local begin="# >>> setup.sh >>>" end="# <<< setup.sh <<<"
    touch "${bashrc}"
    backup_once "${bashrc}"
    sed -i "/${begin}/,/${end}/d" "${bashrc}"
    cat >> "${bashrc}" <<EOF
${begin}
alias emacs='XMODIFIERS=@im=none emacs'
alias venv-main='source \$HOME/venvs/myproject_env/bin/activate'
alias venv-mp='source \$HOME/venvs/mediapipe_env/bin/activate'
case \$TERM in
    linux) LANG=C ;;
esac
export PYENV_ROOT="\$HOME/.pyenv"
[ -d "\$PYENV_ROOT/bin" ] && export PATH="\$PYENV_ROOT/bin:\$PATH"
command -v pyenv >/dev/null 2>&1 && eval "\$(pyenv init -)"
command -v pyenv >/dev/null 2>&1 && eval "\$(pyenv virtualenv-init -)"
${end}
EOF

    record_manual "Python の作業は仮想環境で行う。主環境は 'source ~/venvs/myproject_env/bin/activate'(別名 venv-main),MediaPipe は 'source ~/venvs/mediapipe_env/bin/activate'(別名 venv-mp)である。"
    record_manual "設定ファイルは本スクリプトに埋め込んである。組織固有の内容がある場合は B70 の該当ヒアドキュメントを書き換えること。既存ファイルは .DIST として退避してある。"
    record_manual "入力ソースとユーザディレクトリ名の変更は再ログイン後に反映される。"
}

# ============================================================
# B80 不要ファイル削除・後処理
# ============================================================
block_80_cleanup() {
    step "クラッシュ情報と古いログの削除"
    sudo rm -f /var/crash/*
    sudo rm -f /var/log/Xorg.0.log.old
    sudo journalctl --vacuum-time=7d || record_failure "journalctl --vacuum-time"

    step "利用者のキャッシュと一時ファイルの削除"
    local t
    for t in .thumbnails .cache/thumbnails .xsession-errors .xsession-errors.old; do
        rm -rf "${HOME:?}/${t}"
    done

    step "APT キャッシュの整理"
    apt_autoremove || record_failure "autoremove"
    apt_clean      || record_failure "apt-get clean"

    step "依存関係の最終確認"
    apt_fix || record_failure "apt-get -f install"

    step "ディスク使用量"
    df -h /

    record_manual "不具合の調査は journalctl で行う(-b 今回の起動以降 / -p err エラー以上 / -u <ユニット名> / -f 追尾)。"
}

# ============================================================
# B90 リマスター準備(--remaster 指定時のみ)
#     注意: 鍵・履歴・ブラウザ設定を削除する。通常運用の端末では実行しない。
# ============================================================
block_90_remaster() {
    step "リマスター用ツールの導入"
    # 24.04 以降の ISO は BIOS / UEFI とも GRUB で起動するため isolinux は用いない。
    apt_install squashfs-tools casper

    step "システムの最終更新"
    apt_update  || record_fatal "apt-get update に失敗(イメージ内容が確定しない)"
    apt_upgrade || record_failure "apt-get dist-upgrade"

    step "利用者データの消去(${TARGET_USER})"
    local t
    for t in .ssh .cache .thumbnails .gvfs .mozilla .config/chromium \
             .bash_history .lesshst .nano_history .sqlite_history \
             .local/share/Trash .local/share/recently-used.xbel; do
        rm -rf "${HOME:?}/${t}"
    done
    mkdir -p "${HOME}/Desktop"

    step "ログの消去"
    sudo journalctl --vacuum-time=1d || record_failure "journalctl --vacuum-time"
    apt_clean || record_failure "apt-get clean"

    record_manual "イメージ作成前に再起動して構成を確定させること。ISO の作成(squashfs 生成と xorriso による作成)は本スクリプトでは行わない。"
}

# ============================================================
# B95 導入結果の確認(OK / NG 判定)
#     状態を変更しない。単体で実行する場合は --check を指定する。
#     判定項目は「Ubuntu 22.04/24.04/26.04 開発・研究環境構築ガイド」の
#     確認章に掲載した check-install.sh と同一である。
# ============================================================
chk() {   # chk <表示名> <コマンド...>
    local n="$1"; shift
    if "$@" >/dev/null 2>&1; then
        echo "   [OK] ${n}"
    else
        echo "   [NG] ${n}"
        CHECK_NG+=("${n}")
    fi
}

final_check() {
    local PY="python3" MP="python3"
    [ -x "${VENV_MAIN}/bin/python" ] && PY="${VENV_MAIN}/bin/python"
    [ -x "${VENV_MP}/bin/python" ]   && MP="${VENV_MP}/bin/python"

    echo
    echo "============================================================"
    echo "[B95  ] 導入結果の確認(OK / NG)"
    echo "============================================================"
    CHECK_NG=()

    echo "--- 開発環境 ---"
    chk gcc gcc --version
    chk g++ g++ --version
    chk gfortran gfortran --version
    chk make make --version
    chk cmake cmake --version
    chk ninja ninja --version
    chk ccache ccache --version
    chk git git --version
    chk subversion svn --version
    chk pkg-config pkg-config --version
    chk OpenCV-dev pkg-config --exists opencv4
    chk Eigen3 pkg-config --exists eigen3
    chk PCL sh -c 'ls /usr/include/pcl-*/pcl/point_types.h'
    chk dlib-C++ sh -c 'ldconfig -p | grep -q libdlib'

    echo "--- NVIDIA GPU ---"
    chk NVIDIA-driver nvidia-smi
    chk CUDA-Toolkit sh -c 'nvcc --version || /usr/local/cuda/bin/nvcc --version'
    chk cuDNN sh -c 'ldconfig -p | grep -q libcudnn'

    echo "--- Python ---"
    chk pyenv sh -c 'test -x "$HOME/.pyenv/bin/pyenv"'
    chk venv-main sh -c 'test -x "$HOME/venvs/myproject_env/bin/python"'
    chk venv-mediapipe sh -c 'test -x "$HOME/venvs/mediapipe_env/bin/python"'
    chk pip "${PY}" -m pip --version
    chk NumPy "${PY}" -c "import numpy"
    chk SciPy "${PY}" -c "import scipy"
    chk pandas "${PY}" -c "import pandas"
    chk Matplotlib "${PY}" -c "import matplotlib"
    chk scikit-learn "${PY}" -c "import sklearn"
    chk scikit-image "${PY}" -c "import skimage"
    chk statsmodels "${PY}" -c "import statsmodels"
    chk seaborn "${PY}" -c "import seaborn"
    chk SymPy "${PY}" -c "import sympy"
    chk Pillow "${PY}" -c "import PIL"
    chk h5py "${PY}" -c "import h5py"
    chk plotly "${PY}" -c "import plotly"
    chk OpenCV-Python "${PY}" -c "import cv2"
    chk dlib-Python "${PY}" -c "import dlib"
    chk psycopg2 "${PY}" -c "import psycopg2"
    chk JupyterLab sh -c 'test -x "$HOME/venvs/myproject_env/bin/jupyter-lab" || command -v jupyter-lab'
    chk Spyder sh -c 'test -x "$HOME/venvs/myproject_env/bin/spyder" || command -v spyder'
    chk ONNXRuntime "${PY}" -c "import onnxruntime"
    chk InsightFace "${PY}" -c "import insightface"
    chk InsightFace-model test -d "${HOME}/.insightface/models/buffalo_l"
    chk PyTorch "${PY}" -c "import torch"
    chk PyTorch-CUDA "${PY}" -c "import torch,sys; sys.exit(0 if torch.cuda.is_available() else 1)"
    chk TensorFlow "${PY}" -c "import tensorflow"
    chk MediaPipe "${MP}" -c "import mediapipe"
    chk MediaPipe-Tasks "${MP}" -c "import mediapipe.tasks.python"
    chk MediaPipe-model test -f "${HOME}/mediapipe_models/face_landmarker.task"
    chk GDAL-Python "${PY}" -c "from osgeo import gdal"
    chk geopandas "${PY}" -c "import geopandas"

    echo "--- エディタ・IDE ---"
    chk VSCode command -v code
    chk SublimeText command -v subl
    chk Geany command -v geany
    chk Emacs command -v emacs
    chk Eclipse sh -c 'command -v eclipse || test -x /usr/local/eclipse/eclipse'
    chk テキストエディタ sh -c 'command -v gnome-text-editor || command -v gedit || command -v featherpad'

    echo "--- ブラウザ・リモート ---"
    chk Firefox command -v firefox
    chk Chrome sh -c 'command -v google-chrome || command -v google-chrome-stable'
    chk Chromium sh -c 'command -v chromium || command -v chromium-browser'
    chk OpenSSH-client command -v ssh
    chk OpenSSH-server sh -c 'systemctl is-enabled ssh.socket || systemctl is-enabled ssh.service || systemctl is-active ssh'
    chk xrdp systemctl is-active xrdp
    chk Remmina command -v remmina
    chk FileZilla command -v filezilla
    chk sshfs command -v sshfs

    echo "--- ユーティリティ ---"
    chk curl command -v curl
    chk wget command -v wget
    chk htop command -v htop
    chk tree command -v tree
    chk jq command -v jq
    chk tmux command -v tmux
    chk screen command -v screen
    chk byobu command -v byobu
    chk plocate command -v plocate
    chk catfish command -v catfish
    chk flameshot command -v flameshot
    chk inotify-tools command -v inotifywait
    chk 7z command -v 7z
    chk trash-cli command -v trash-put
    chk deja-dup command -v deja-dup
    chk GParted command -v gparted
    chk Synaptic command -v synaptic
    chk GNOME-Tweaks command -v gnome-tweaks
    chk hardinfo sh -c 'command -v hardinfo2 || command -v hardinfo'

    echo "--- ネットワーク診断・セキュリティ ---"
    chk nmap command -v nmap
    chk tcpdump command -v tcpdump
    chk Wireshark command -v wireshark
    chk ClamAV command -v clamscan
    chk Lynis command -v lynis
    chk netcat command -v nc
    chk intel-gpu-tools command -v intel_gpu_top

    echo "--- データベース ---"
    chk PostgreSQL-server sh -c 'ls /usr/lib/postgresql/*/bin/postgres'
    chk PostgreSQL-service pg_isready
    chk psql command -v psql
    chk PostGIS sh -c 'ls /usr/share/postgresql/*/extension/postgis.control'
    chk PL/Python sh -c 'ls /usr/share/postgresql/*/extension/plpython3u.control'
    chk pgAdmin4 sh -c 'command -v pgadmin4 || test -d /usr/pgadmin4'
    chk DBeaver command -v dbeaver
    chk SQLite3 command -v sqlite3
    chk SQLiteBrowser command -v sqlitebrowser
    chk SpatiaLite command -v spatialite
    chk Redis sh -c 'redis-cli ping | grep -q PONG'
    chk MongoDB command -v mongod

    echo "--- 画像・映像・3D・空間データ ---"
    chk ffmpeg command -v ffmpeg
    chk sox command -v sox
    chk VLC command -v vlc
    chk Audacity command -v audacity
    chk OBS-Studio command -v obs
    chk OpenShot command -v openshot-qt
    chk Kdenlive command -v kdenlive
    chk guvcview command -v guvcview
    chk GIMP command -v gimp
    chk Inkscape command -v inkscape
    chk Pinta command -v pinta
    chk darktable command -v darktable
    chk RawTherapee command -v rawtherapee
    chk ImageMagick sh -c 'command -v magick || command -v convert'
    chk GraphicsMagick command -v gm
    chk rsvg-convert command -v rsvg-convert
    chk GraphViz command -v dot
    chk gnuplot command -v gnuplot
    chk Blender command -v blender
    chk MeshLab command -v meshlab
    chk CloudCompare sh -c 'command -v CloudCompare || command -v cloudcompare.CloudCompare'
    chk GDAL command -v gdalinfo
    chk QGIS command -v qgis
    chk GRASS-GIS command -v grass
    chk Google-Earth sh -c 'command -v google-earth-pro || test -x /opt/google/earth/pro/googleearth'
    chk drawio command -v drawio

    echo "--- 日本語処理・文書変換 ---"
    chk MeCab sh -c 'echo 日本語 | mecab'
    chk nkf command -v nkf
    chk pdftotext command -v pdftotext
    chk html2text command -v html2text
    chk 日本語フォント sh -c 'fc-list | grep -qi "Noto Sans CJK"'
    chk Mozc sh -c 'dpkg -l ibus-mozc fcitx5-mozc 2>/dev/null | grep -q "^ii"'

    echo "--- Java・R・その他言語 ---"
    chk Java-JRE java -version
    chk Java-JDK javac -version
    chk R R --version
    chk Rscript Rscript --version
    chk RStudio command -v rstudio
    chk Node.js node --version
    chk npm npm --version

    echo "--- ゲーム ---"
    chk 0ad command -v 0ad
    chk SuperTuxKart command -v supertuxkart
    chk Wesnoth sh -c 'command -v wesnoth || ls /usr/games/wesnoth*'
    chk GNOME-Mahjongg command -v gnome-mahjongg
    chk ExtremeTuxRacer command -v etr
    chk Aisleriot command -v sol
    chk Warzone2100 command -v warzone2100
    chk Hedgewars command -v hedgewars
    chk FrozenBubble command -v frozen-bubble

    echo
    if [ "${#CHECK_NG[@]}" -eq 0 ]; then
        echo "確認結果: すべて OK"
    else
        echo "確認結果: NG ${#CHECK_NG[@]} 件(レポートに一覧を表示する)"
        record_manual "導入確認(B95)で NG となった項目がある。当該アプリの導入手順と普通のエラー一覧を照合すること。GPU 非搭載機では NVIDIA 関連が NG となるのは正常である。"
    fi
}

# ============================================================
# 主処理
# ============================================================
for a in "$@"; do
    case "$a" in
        --remaster) DO_REMASTER=1 ;;
        --reset)    DO_RESET=1 ;;
        --check)    DO_CHECK=1 ;;
        *) echo "使用法: $(basename "$0") [--remaster] [--reset] [--check]"; exit 2 ;;
    esac
done

if [ "${DO_CHECK}" -eq 1 ]; then
    detect_env
    final_check
    report
    exit 0
fi

preflight

run_block B10 "システム基盤"                block_10_base
run_block B20 "リポジトリ登録"              block_20_repositories
run_block B30 "アプリ導入(apt)"             block_30_apps_apt
run_block B40 "アプリ導入(ソース)"          block_40_apps_source
run_block B50 "不要パッケージ削除"          block_50_remove_packages
run_block B60 "システム設定・サービス停止"  block_60_system
run_block B70 "ユーザ設定"                  block_70_user
run_block B80 "不要ファイル削除・後処理"    block_80_cleanup
if [ "${DO_REMASTER}" -eq 1 ]; then
    run_block B90 "リマスター準備"          block_90_remaster
fi

step "APT 自動更新ユニットの再開"
apt_restore

final_check

report
echo "全ブロックが完了した。設定を反映するため再起動すること。"
echo "確認だけを再実行する場合は ${SCRIPT_PATH} --check を実行する。"
echo "最初からやり直す場合は ${SCRIPT_PATH} --reset を実行する。"
exit 0

スクリプト作成方針

構成

エラーの扱い

実装

内容