Ubuntu / Debian Setup Manager
A collection of hardened install and maintenance scripts for a fresh Ubuntu / Debian desktop, driven by an interactive menu.sh. Each installer auto-detects the distro, uses signed-by APT keyrings where applicable (no apt-key), and is idempotent (safe to re-run).
Quick start — run the menu
One-liner — fetches menu.sh and runs it. You'll see a numeric picker and can select one option, several, or all:
bash -c "$(curl -fsSL https://tinyurl.com/bdddmxc4)"
How to use the picker:
- Enter one or more numbers separated by spaces, e.g.
1 6 11 0runs setup options 1-23 in order26updates installed software from options 1-7 and 9-20; software that is not installed is skipped-1exits
The menu caches sudo credentials up-front so multi-task runs don't keep re-prompting, and falls back to per-user execution for scripts that must not run as root (JetBrains Toolbox, IBus Pinyin, Nerd Fonts, screenshots cleanup, and Thunderbird backup).
Available scripts
| # | Category | Script | Runs as | What it does |
|---|---|---|---|---|
| 1 | Browsers & Mail | install-chrome.sh |
sudo | Installs Google Chrome Stable from Google's official APT repo using a signed-by keyring. |
| 2 | Browsers & Mail | install-firefox.sh |
sudo | Removes the Firefox Snap and installs Firefox from Mozilla's official APT repo, with APT pinning so it stays on the Mozilla build. Verifies the Mozilla signing-key fingerprint and best-effort pins Firefox at GNOME Dock position 2. |
| 3 | Browsers & Mail | install-thunderbird.sh |
sudo | Ubuntu: removes the Snap, adds the Mozilla Team PPA, and pins it. Debian: installs from the standard repos. |
| 4 | Communication | install-telegram.sh |
sudo | Installs the latest official Telegram Desktop Linux build from Telegram's latest download endpoint. Adds launcher and tg: URL handler. |
| 5 | Communication | install-discord.sh |
sudo | Installs the latest official Discord Linux .deb from Discord's latest download endpoint. |
| 6 | Files, Downloads & Storage | install-localsend.sh |
sudo | Installs the latest official LocalSend .deb from localsend/localsend. SHA-256 verified against the GitHub release asset digest when available. |
| 7 | Files, Downloads & Storage | install-qbittorrent.sh |
sudo | Installs the latest official qBittorrent x86_64 AppImage from qbittorrent/qBittorrent. SHA-256 verified against the GitHub release asset digest when available. Installs launcher, icons, and torrent/magnet handlers. |
| 8 | Files, Downloads & Storage | install-network_drive.sh |
sudo | Discovers SMB shares on a Synology NAS and adds them to /etc/fstab under /mnt/Synology with x-systemd.automount. fstab block is managed via begin/end markers so re-runs replace rather than duplicate. Credentials file is 0600. Best-effort GNOME Dock pin. |
| 9 | Productivity & Security | install-libreoffice.sh |
sudo | Detects the latest stable release on documentfoundation.org, downloads the matching .deb tarball, verifies the published MD5, and installs. Purges the distro's libreoffice* first to avoid library conflicts (opt-out with --keep-distro-libreoffice). |
| 10 | Productivity & Security | install-obsidian.sh |
sudo | Installs the latest official Obsidian amd64 .deb from obsidianmd/obsidian-releases. SHA-256 verified against the GitHub release asset digest when available. Honors $GITHUB_TOKEN to avoid API rate limits. |
| 11 | Productivity & Security | install-marktext.sh |
sudo | Installs the latest official MarkText amd64 .deb from marktext/marktext. SHA-256 verified against the GitHub release asset digest when available. Honors $GITHUB_TOKEN to avoid API rate limits. |
| 12 | Productivity & Security | install-drawio.sh |
sudo | Installs the latest official draw.io Desktop .deb from jgraph/drawio-desktop. SHA-256 verified against the GitHub release asset digest when available. Honors $GITHUB_TOKEN to avoid API rate limits. |
| 13 | Productivity & Security | install-espanso.sh |
sudo | Installs Espanso. Auto-detects Wayland vs X11 from $XDG_SESSION_TYPE and registers the systemd-user service as the invoking desktop user (not root). |
| 14 | Productivity & Security | install-1password.sh |
sudo | Configures the 1Password APT repo with debsig signature policy. Arch-aware (amd64 / arm64). |
| 15 | Development Tools | install-vscode.sh |
sudo | Microsoft's official code APT repo, signed-by keyring. --insiders flag installs code-insiders instead. |
| 16 | Development Tools | install-jetbrains-toolbox.sh |
user | Per-user install into ~/.local/share/JetBrains/Toolbox. SHA-256 verified against JetBrains' release feed. x86_64 + aarch64. Drops a .desktop launcher. |
| 17 | Development Tools | install-bruno.sh |
sudo | Bruno API client from the official APT repo. Keyserver fetch is wrapped in a 5-attempt retry/backoff because keyserver.ubuntu.com is occasionally flaky. |
| 18 | Development Tools | install-ipatool.sh |
sudo | Installs the latest release of majd/ipatool from GitHub. SHA-256 verified against the release checksums.txt. Honors $GITHUB_TOKEN to avoid API rate limits. |
| 19 | Desktop & Localization | install-ibus-pinyin.sh |
user | Installs ibus-libpinyin and Simplified Chinese language packs, restarts the IBus daemon, and idempotently adds ('ibus', 'libpinyin') to GNOME's input sources via gsettings. |
| 20 | Desktop & Localization | install-font.sh |
user | Installs the latest Ubuntu Sans Nerd Font and JetBrains Mono Nerd Font to ~/.local/share/fonts, then refreshes the font cache. No sudo needed. |
| 21 | System & Maintenance | install-screenshot-cleanup-cron.sh |
user | Installs an idempotent per-user cron job that clears $HOME/Pictures/Screenshots every 5 minutes while preserving the directory itself. |
| 22 | System & Maintenance | timedatectl-fix.sh |
sudo | Sets the hardware clock to UTC to avoid time drift when dual-booting Linux and Windows. |
| 23 | System & Maintenance | disable-grub-countdown.sh |
sudo | Configures GRUB to show the dual-boot menu with no countdown by setting GRUB_TIMEOUT_STYLE=menu and GRUB_TIMEOUT=-1, then regenerates the GRUB config. |
| 24 | Backup | install-thunderbird-backup-cron.sh |
user | Installs an hourly cron job that fetches and runs the hosted Thunderbird-to-Synology backup script without storing it locally. Not included in menu option 0; select it explicitly. |
| 26 | System & Maintenance | Built into menu.sh |
mixed | Detects and updates installed software from options 1-7 and 9-20. Software that is not installed is logged and skipped. Excludes network-drive, cron, system configuration, backup, and sync tasks. |
"Runs as user" entries must be invoked as your normal desktop user, not via sudo. The other entries elevate via sudo internally and the menu primes sudo -v up-front, so you'll only be prompted once.
Running a single script directly
If you'd rather skip the menu, each script can be run on its own. Use the right invocation pattern for that script's privilege mode:
# sudo scripts (1-15, 17-18, 22-23) — pipe through sudo bash
curl -fsSL https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD/install-firefox.sh | sudo bash
# user scripts (16, 19-21, 24) — DO NOT use sudo; they install or run per-user
bash -c "$(curl -fsSL https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD/install-font.sh)"
Most installers and utilities accept --help and --dry-run (the latter prints what would happen without executing). For example:
curl -fsSL https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD/install-libreoffice.sh \
| bash -s -- --help
Thunderbird to Synology Backup
The Thunderbird backup utility reads its default config from this gist:
https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD/thunderbird-backup.conf
Run a backup manually:
bash -c "$(curl -fsSL https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD/backup-thunderbird-to-synology.sh)"
Preview changes without writing files:
curl -fsSL https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD/backup-thunderbird-to-synology.sh \
| bash -s -- --dry-run
To change the backup location for every machine using the hosted config, edit thunderbird-backup.conf in this gist and update DESTINATION; if the backup location is on a different mounted drive or NAS share, update MOUNTPOINT too. The script parses only SOURCE, DESTINATION, MOUNTPOINT, LOG_FILE, and LOCK_FILE; unsupported keys or invalid lines stop the run.
Menu option 24 installs this hourly user cron job idempotently and removes legacy entries that invoke a locally stored backup script:
0 * * * * /usr/bin/bash -o pipefail -c '/usr/bin/curl --proto "=https" --tlsv1.2 -fsSL --max-time 60 "https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD/backup-thunderbird-to-synology.sh" | /usr/bin/bash' >> "$HOME/.local/state/thunderbird-cron.log" 2>&1 # thunderbird-synology-backup
The current hosted script is downloaded on every run and executes locally because the Thunderbird profile and Synology mount are local. Changes pushed to the hosted script therefore take effect on the next hourly run.
What "hardened" means here
The hardened installers generally share this robustness baseline:
set -euo pipefail+ IFS hygiene +ERRtrap reporting the failing line number/etc/os-releasedistro auto-detection (no hardcoded codenames)dpkg --print-architecture/uname -mfor architecture (amd64 / arm64 / armhf / x86_64 / aarch64 as applicable to each upstream)- Idempotent — re-running a script does not duplicate APT sources, fstab entries, gsettings entries, or
.desktopfiles signed-bykeyrings in/etc/apt/keyrings(no deprecatedapt-key add)- Checksum verification where the upstream publishes one (JetBrains SHA-256, LibreOffice MD5, Obsidian SHA-256, MarkText SHA-256, ipatool SHA-256, draw.io SHA-256, qBittorrent SHA-256, LocalSend SHA-256)
- Per-user installers refuse to run as root; system installers refuse to run as non-root
Hide Drive from the Dock
gsettings set org.gnome.shell.extensions.dash-to-dock show-mounts false
Supported distros
- Ubuntu (any modern release; some scripts target Ubuntu 25.10 specifically but work elsewhere)
- Debian (most scripts;
install-thunderbird.shtakes a different code path since Debian has no PPAs)
Other distros are rejected up-front rather than failing later in unpredictable ways.
| 1 | #!/usr/bin/env bash |
| 2 | set -euo pipefail |
| 3 | |
| 4 | CONFIG_URL_DEFAULT="https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD/thunderbird-backup.conf" |
| 5 | CONFIG_FILE="${THUNDERBIRD_BACKUP_CONFIG:-}" |
| 6 | CONFIG_URL="${THUNDERBIRD_BACKUP_CONFIG_URL:-$CONFIG_URL_DEFAULT}" |
| 7 | SHOW_HELP=false |
| 8 | |
| 9 | args=("$@") |
| 10 | for ((i = 0; i < ${#args[@]}; i++)); do |
| 11 | case "${args[$i]}" in |
| 12 | --config) |
| 13 | CONFIG_FILE="${args[$((i + 1))]:?Missing value for --config}" |
| 14 | CONFIG_URL="" |
| 15 | ;; |
| 16 | --config-url) |
| 17 | CONFIG_URL="${args[$((i + 1))]:?Missing value for --config-url}" |
| 18 | CONFIG_FILE="" |
| 19 | ;; |
| 20 | -h|--help) |
| 21 | SHOW_HELP=true |
| 22 | ;; |
| 23 | esac |
| 24 | done |
| 25 | |
| 26 | SOURCE_DEFAULT="/home/vernon/.thunderbird/" |
| 27 | DESTINATION_DEFAULT="/mnt/Synology/Backup/Applications/Thunderbird/" |
| 28 | MOUNTPOINT_DEFAULT="/mnt/Synology/Backup" |
| 29 | LOG_FILE_DEFAULT="/home/vernon/.local/state/thunderbird-backup.log" |
| 30 | LOCK_FILE_DEFAULT="/home/vernon/.cache/thunderbird-backup.lock" |
| 31 | DRY_RUN=false |
| 32 | |
| 33 | SOURCE="$SOURCE_DEFAULT" |
| 34 | DESTINATION="$DESTINATION_DEFAULT" |
| 35 | MOUNTPOINT="$MOUNTPOINT_DEFAULT" |
| 36 | LOG_FILE="$LOG_FILE_DEFAULT" |
| 37 | LOCK_FILE="$LOCK_FILE_DEFAULT" |
| 38 | |
| 39 | trim() { |
| 40 | local value="$1" |
| 41 | value="${value#"${value%%[![:space:]]*}"}" |
| 42 | value="${value%"${value##*[![:space:]]}"}" |
| 43 | printf '%s' "$value" |
| 44 | } |
| 45 | |
| 46 | parse_config_content() { |
| 47 | local line key raw value |
| 48 | |
| 49 | while IFS= read -r line || [[ -n "$line" ]]; do |
| 50 | line="$(trim "$line")" |
| 51 | [[ -z "$line" || "$line" == \#* ]] && continue |
| 52 | |
| 53 | if [[ ! "$line" =~ ^([A-Z_]+)[[:space:]]*=(.*)$ ]]; then |
| 54 | echo "ERROR: Invalid config line: $line" >&2 |
| 55 | exit 2 |
| 56 | fi |
| 57 | |
| 58 | key="${BASH_REMATCH[1]}" |
| 59 | raw="$(trim "${BASH_REMATCH[2]}")" |
| 60 | |
| 61 | if [[ "$raw" == \"*\" && "$raw" == *\" ]]; then |
| 62 | value="${raw:1:${#raw}-2}" |
| 63 | elif [[ "$raw" == \'*\' && "$raw" == *\' ]]; then |
| 64 | value="${raw:1:${#raw}-2}" |
| 65 | else |
| 66 | value="$raw" |
| 67 | fi |
| 68 | |
| 69 | case "$key" in |
| 70 | SOURCE) SOURCE="$value" ;; |
| 71 | DESTINATION) DESTINATION="$value" ;; |
| 72 | MOUNTPOINT) MOUNTPOINT="$value" ;; |
| 73 | LOG_FILE) LOG_FILE="$value" ;; |
| 74 | LOCK_FILE) LOCK_FILE="$value" ;; |
| 75 | *) |
| 76 | echo "ERROR: Unsupported config key: $key" >&2 |
| 77 | exit 2 |
| 78 | ;; |
| 79 | esac |
| 80 | done |
| 81 | } |
| 82 | |
| 83 | usage() { |
| 84 | cat <<'USAGE' |
| 85 | Usage: backup-thunderbird-to-synology.sh [options] |
| 86 | |
| 87 | Options: |
| 88 | --source PATH Thunderbird profile directory to back up |
| 89 | --destination PATH Backup destination directory |
| 90 | --mountpoint PATH Required mounted path to check before backup |
| 91 | --no-mount-check Disable the mountpoint safety check |
| 92 | --log-file PATH Log file path |
| 93 | --config PATH Local config file path; disables remote config |
| 94 | --config-url URL Remote config URL |
| 95 | --dry-run Show what would change without writing files |
| 96 | -h, --help Show this help |
| 97 | |
| 98 | Config defaults to: |
| 99 | https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD/thunderbird-backup.conf |
| 100 | |
| 101 | Supported config variables: SOURCE, DESTINATION, MOUNTPOINT, LOG_FILE, LOCK_FILE. |
| 102 | Environment overrides: THUNDERBIRD_BACKUP_SOURCE, THUNDERBIRD_BACKUP_DESTINATION, |
| 103 | THUNDERBIRD_BACKUP_MOUNTPOINT, THUNDERBIRD_BACKUP_LOG_FILE, THUNDERBIRD_BACKUP_LOCK_FILE, |
| 104 | THUNDERBIRD_BACKUP_CONFIG, THUNDERBIRD_BACKUP_CONFIG_URL. |
| 105 | USAGE |
| 106 | } |
| 107 | |
| 108 | if [[ "$SHOW_HELP" == true ]]; then |
| 109 | usage |
| 110 | exit 0 |
| 111 | fi |
| 112 | |
| 113 | if [[ -n "$CONFIG_FILE" ]]; then |
| 114 | if [[ ! -f "$CONFIG_FILE" ]]; then |
| 115 | mkdir -p "$(dirname "$LOG_FILE")" |
| 116 | echo "$(date -Is) ERROR: Config file not found: $CONFIG_FILE" >> "$LOG_FILE" |
| 117 | exit 1 |
| 118 | fi |
| 119 | parse_config_content < "$CONFIG_FILE" |
| 120 | elif [[ -n "$CONFIG_URL" ]]; then |
| 121 | config_content="$(curl -fsSL --max-time 20 "$CONFIG_URL")" || { |
| 122 | mkdir -p "$(dirname "$LOG_FILE")" |
| 123 | echo "$(date -Is) ERROR: Unable to fetch config from $CONFIG_URL" >> "$LOG_FILE" |
| 124 | exit 1 |
| 125 | } |
| 126 | parse_config_content <<< "$config_content" |
| 127 | fi |
| 128 | |
| 129 | SOURCE="${THUNDERBIRD_BACKUP_SOURCE:-$SOURCE}" |
| 130 | DESTINATION="${THUNDERBIRD_BACKUP_DESTINATION:-$DESTINATION}" |
| 131 | MOUNTPOINT="${THUNDERBIRD_BACKUP_MOUNTPOINT:-$MOUNTPOINT}" |
| 132 | LOG_FILE="${THUNDERBIRD_BACKUP_LOG_FILE:-$LOG_FILE}" |
| 133 | LOCK_FILE="${THUNDERBIRD_BACKUP_LOCK_FILE:-$LOCK_FILE}" |
| 134 | |
| 135 | while [[ $# -gt 0 ]]; do |
| 136 | case "$1" in |
| 137 | --source) |
| 138 | SOURCE="${2:?Missing value for --source}" |
| 139 | shift 2 |
| 140 | ;; |
| 141 | --destination) |
| 142 | DESTINATION="${2:?Missing value for --destination}" |
| 143 | shift 2 |
| 144 | ;; |
| 145 | --mountpoint) |
| 146 | MOUNTPOINT="${2:?Missing value for --mountpoint}" |
| 147 | shift 2 |
| 148 | ;; |
| 149 | --no-mount-check) |
| 150 | MOUNTPOINT="" |
| 151 | shift |
| 152 | ;; |
| 153 | --log-file) |
| 154 | LOG_FILE="${2:?Missing value for --log-file}" |
| 155 | shift 2 |
| 156 | ;; |
| 157 | --dry-run) |
| 158 | DRY_RUN=true |
| 159 | shift |
| 160 | ;; |
| 161 | --config) |
| 162 | shift 2 |
| 163 | ;; |
| 164 | --config-url) |
| 165 | shift 2 |
| 166 | ;; |
| 167 | -h|--help) |
| 168 | usage |
| 169 | exit 0 |
| 170 | ;; |
| 171 | *) |
| 172 | echo "ERROR: Unknown option: $1" >&2 |
| 173 | usage >&2 |
| 174 | exit 2 |
| 175 | ;; |
| 176 | esac |
| 177 | done |
| 178 | |
| 179 | mkdir -p "$(dirname "$LOG_FILE")" "$(dirname "$LOCK_FILE")" |
| 180 | |
| 181 | timestamp() { |
| 182 | date -Is |
| 183 | } |
| 184 | |
| 185 | normalize_path() { |
| 186 | realpath -m "$1" |
| 187 | } |
| 188 | |
| 189 | SOURCE_REAL="$(normalize_path "$SOURCE")" |
| 190 | DESTINATION_REAL="$(normalize_path "$DESTINATION")" |
| 191 | |
| 192 | if [[ "$SOURCE" != /* || "$DESTINATION" != /* || "$LOG_FILE" != /* || "$LOCK_FILE" != /* ]]; then |
| 193 | echo "$(timestamp) ERROR: SOURCE, DESTINATION, LOG_FILE, and LOCK_FILE must be absolute paths." >> "$LOG_FILE" |
| 194 | exit 1 |
| 195 | fi |
| 196 | |
| 197 | if [[ -n "$MOUNTPOINT" && "$MOUNTPOINT" != /* ]]; then |
| 198 | echo "$(timestamp) ERROR: MOUNTPOINT must be an absolute path." >> "$LOG_FILE" |
| 199 | exit 1 |
| 200 | fi |
| 201 | |
| 202 | if [[ "$SOURCE_REAL" == "$DESTINATION_REAL" ]]; then |
| 203 | echo "$(timestamp) ERROR: SOURCE and DESTINATION cannot be the same path: $SOURCE_REAL" >> "$LOG_FILE" |
| 204 | exit 1 |
| 205 | fi |
| 206 | |
| 207 | if [[ "$DESTINATION_REAL" == "$SOURCE_REAL"/* ]]; then |
| 208 | echo "$(timestamp) ERROR: DESTINATION cannot be inside SOURCE: $DESTINATION_REAL" >> "$LOG_FILE" |
| 209 | exit 1 |
| 210 | fi |
| 211 | |
| 212 | case "$DESTINATION_REAL" in |
| 213 | /|/home|/home/vernon|/mnt|/mnt/Synology|/mnt/Synology/Backup) |
| 214 | echo "$(timestamp) ERROR: Refusing unsafe backup destination: $DESTINATION_REAL" >> "$LOG_FILE" |
| 215 | exit 1 |
| 216 | ;; |
| 217 | esac |
| 218 | |
| 219 | if [[ -n "$MOUNTPOINT" ]] && ! mountpoint -q "$MOUNTPOINT"; then |
| 220 | echo "$(timestamp) ERROR: $MOUNTPOINT is not mounted; backup skipped." >> "$LOG_FILE" |
| 221 | exit 1 |
| 222 | fi |
| 223 | |
| 224 | if [[ ! -d "$SOURCE" ]]; then |
| 225 | echo "$(timestamp) ERROR: Thunderbird profile directory not found: $SOURCE" >> "$LOG_FILE" |
| 226 | exit 1 |
| 227 | fi |
| 228 | |
| 229 | mkdir -p "$DESTINATION" |
| 230 | |
| 231 | exec 9>"$LOCK_FILE" |
| 232 | if ! flock -n 9; then |
| 233 | echo "$(timestamp) INFO: Previous Thunderbird backup still running; backup skipped." >> "$LOG_FILE" |
| 234 | exit 0 |
| 235 | fi |
| 236 | |
| 237 | { |
| 238 | if [[ "$DRY_RUN" == true ]]; then |
| 239 | echo "$(timestamp) INFO: Starting Thunderbird backup dry run: $SOURCE_REAL -> $DESTINATION_REAL" |
| 240 | else |
| 241 | echo "$(timestamp) INFO: Starting Thunderbird backup: $SOURCE_REAL -> $DESTINATION_REAL" |
| 242 | fi |
| 243 | |
| 244 | rsync_args=(-a --delete) |
| 245 | if [[ "$DRY_RUN" == true ]]; then |
| 246 | rsync_args+=(-n --itemize-changes) |
| 247 | fi |
| 248 | |
| 249 | rsync "${rsync_args[@]}" \ |
| 250 | --exclude='*.parentlock' \ |
| 251 | --exclude='lock' \ |
| 252 | "$SOURCE" "$DESTINATION" |
| 253 | if [[ "$DRY_RUN" == true ]]; then |
| 254 | echo "$(timestamp) INFO: Thunderbird backup dry run completed." |
| 255 | else |
| 256 | echo "$(timestamp) INFO: Thunderbird backup completed." |
| 257 | fi |
| 258 | } >> "$LOG_FILE" 2>&1 |
| 259 |
| 1 | #!/usr/bin/env bash |
| 2 | # disable-grub-countdown.sh — Show the GRUB dual-boot menu without a countdown. |
| 3 | |
| 4 | set -euo pipefail |
| 5 | IFS=$'\n\t' |
| 6 | |
| 7 | readonly SCRIPT_NAME="${0##*/}" |
| 8 | readonly GRUB_DEFAULTS="/etc/default/grub" |
| 9 | DRY_RUN=0 |
| 10 | |
| 11 | usage() { |
| 12 | cat <<EOF |
| 13 | Usage: $SCRIPT_NAME [--dry-run] [--help] |
| 14 | |
| 15 | Configures GRUB to show the boot menu and wait indefinitely: |
| 16 | |
| 17 | GRUB_TIMEOUT_STYLE=menu |
| 18 | GRUB_TIMEOUT=-1 |
| 19 | |
| 20 | Options: |
| 21 | --dry-run Print actions without executing. |
| 22 | --help, -h Show this help. |
| 23 | EOF |
| 24 | } |
| 25 | |
| 26 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 27 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 28 | |
| 29 | while (( $# )); do |
| 30 | case "$1" in |
| 31 | --dry-run) DRY_RUN=1 ;; |
| 32 | -h|--help) usage; exit 0 ;; |
| 33 | *) die "Unknown argument: $1 (try --help)" ;; |
| 34 | esac |
| 35 | shift |
| 36 | done |
| 37 | |
| 38 | (( EUID == 0 )) || die "Please run as root: sudo bash $SCRIPT_NAME" |
| 39 | [[ -f "$GRUB_DEFAULTS" ]] || die "Missing $GRUB_DEFAULTS. This system does not look like a GRUB-based Ubuntu/Debian install." |
| 40 | |
| 41 | for tool in cp date grep mktemp sed; do |
| 42 | command -v "$tool" >/dev/null 2>&1 || die "Missing required tool: $tool" |
| 43 | done |
| 44 | |
| 45 | if command -v update-grub >/dev/null 2>&1; then |
| 46 | GRUB_UPDATE_CMD=(update-grub) |
| 47 | elif command -v grub-mkconfig >/dev/null 2>&1; then |
| 48 | GRUB_UPDATE_CMD=(grub-mkconfig -o /boot/grub/grub.cfg) |
| 49 | elif command -v grub2-mkconfig >/dev/null 2>&1; then |
| 50 | if [[ -d /boot/grub2 ]]; then |
| 51 | GRUB_UPDATE_CMD=(grub2-mkconfig -o /boot/grub2/grub.cfg) |
| 52 | else |
| 53 | GRUB_UPDATE_CMD=(grub2-mkconfig -o /boot/grub/grub.cfg) |
| 54 | fi |
| 55 | else |
| 56 | die "Could not find update-grub, grub-mkconfig, or grub2-mkconfig." |
| 57 | fi |
| 58 | |
| 59 | BACKUP="${GRUB_DEFAULTS}.backup.$(date +%Y%m%d-%H%M%S)" |
| 60 | STAGE="$(mktemp -t grub-defaults.XXXXXX)" |
| 61 | trap 'rm -f "$STAGE"' EXIT |
| 62 | |
| 63 | cp "$GRUB_DEFAULTS" "$STAGE" |
| 64 | |
| 65 | if grep -q '^GRUB_TIMEOUT_STYLE=' "$STAGE"; then |
| 66 | sed -i 's/^GRUB_TIMEOUT_STYLE=.*/GRUB_TIMEOUT_STYLE=menu/' "$STAGE" |
| 67 | else |
| 68 | printf '\nGRUB_TIMEOUT_STYLE=menu\n' >>"$STAGE" |
| 69 | fi |
| 70 | |
| 71 | if grep -q '^GRUB_TIMEOUT=' "$STAGE"; then |
| 72 | sed -i 's/^GRUB_TIMEOUT=.*/GRUB_TIMEOUT=-1/' "$STAGE" |
| 73 | else |
| 74 | printf 'GRUB_TIMEOUT=-1\n' >>"$STAGE" |
| 75 | fi |
| 76 | |
| 77 | if (( DRY_RUN )); then |
| 78 | log "Would back up $GRUB_DEFAULTS to $BACKUP" |
| 79 | log "Would write these GRUB timeout settings:" |
| 80 | grep -E '^GRUB_TIMEOUT_STYLE=|^GRUB_TIMEOUT=' "$STAGE" | sed 's/^/ /' |
| 81 | log "Would run: ${GRUB_UPDATE_CMD[*]}" |
| 82 | exit 0 |
| 83 | fi |
| 84 | |
| 85 | cp "$GRUB_DEFAULTS" "$BACKUP" |
| 86 | cp "$STAGE" "$GRUB_DEFAULTS" |
| 87 | |
| 88 | log "Updated $GRUB_DEFAULTS" |
| 89 | log "Backup saved to $BACKUP" |
| 90 | log "Regenerating GRUB configuration..." |
| 91 | "${GRUB_UPDATE_CMD[@]}" |
| 92 | |
| 93 | log "Done. On next boot, the GRUB menu will wait until you choose an entry." |
| 94 |
| 1 | #!/usr/bin/env bash |
| 2 | # install-1password.sh — Install 1Password desktop from the official 1Password APT repo. |
| 3 | # Hardened: arch-aware, signed-by keyring, debsig verification dir, idempotent, --dry-run. |
| 4 | |
| 5 | set -euo pipefail |
| 6 | IFS=$'\n\t' |
| 7 | |
| 8 | readonly SCRIPT_NAME="${0##*/}" |
| 9 | DRY_RUN=0 |
| 10 | |
| 11 | usage() { |
| 12 | cat <<EOF |
| 13 | Usage: sudo $SCRIPT_NAME [--dry-run] [--help] |
| 14 | |
| 15 | Configures the official 1Password APT repository (with debsig policy and |
| 16 | keyring) and installs the 1Password desktop application. Idempotent: safe to |
| 17 | re-run. |
| 18 | |
| 19 | Options: |
| 20 | --dry-run Print actions without executing. |
| 21 | --help, -h Show this help. |
| 22 | EOF |
| 23 | } |
| 24 | |
| 25 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 26 | warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; } |
| 27 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 28 | run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; } |
| 29 | trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR |
| 30 | |
| 31 | while (( $# )); do |
| 32 | case "$1" in |
| 33 | --dry-run) DRY_RUN=1 ;; |
| 34 | -h|--help) usage; exit 0 ;; |
| 35 | *) die "Unknown argument: $1 (try --help)" ;; |
| 36 | esac |
| 37 | shift |
| 38 | done |
| 39 | |
| 40 | (( EUID == 0 )) || die "Must run as root. Try: sudo $SCRIPT_NAME" |
| 41 | |
| 42 | [[ -r /etc/os-release ]] || die "/etc/os-release not found." |
| 43 | # shellcheck disable=SC1091 |
| 44 | . /etc/os-release |
| 45 | case "${ID:-}:${ID_LIKE:-}" in |
| 46 | *ubuntu*|*debian*) : ;; |
| 47 | *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;; |
| 48 | esac |
| 49 | |
| 50 | # 1Password publishes builds for amd64 and arm64. |
| 51 | ARCH="$(dpkg --print-architecture)" |
| 52 | case "$ARCH" in |
| 53 | amd64|arm64) : ;; |
| 54 | *) die "1Password is only available for amd64/arm64 (detected: $ARCH)." ;; |
| 55 | esac |
| 56 | log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH" |
| 57 | |
| 58 | export DEBIAN_FRONTEND=noninteractive |
| 59 | |
| 60 | KEYRING=/etc/apt/keyrings/1password-archive-keyring.gpg |
| 61 | SOURCES=/etc/apt/sources.list.d/1password.list |
| 62 | DEBSIG_POLICY_DIR=/etc/debsig/policies/AC2D62742012EA22 |
| 63 | DEBSIG_KEYRING_DIR=/usr/share/debsig/keyrings/AC2D62742012EA22 |
| 64 | |
| 65 | log "Installing prerequisites..." |
| 66 | run "apt-get update -qq" |
| 67 | run "apt-get install -y curl gpg ca-certificates" |
| 68 | |
| 69 | log "Configuring 1Password APT repository..." |
| 70 | run "install -d -m 0755 /etc/apt/keyrings" |
| 71 | if [[ ! -s "$KEYRING" ]]; then |
| 72 | run "curl -fsSL https://downloads.1password.com/linux/keys/1password.asc | gpg --dearmor -o '$KEYRING'" |
| 73 | run "chmod 0644 '$KEYRING'" |
| 74 | fi |
| 75 | |
| 76 | DESIRED_SRC="deb [arch=${ARCH} signed-by=${KEYRING}] https://downloads.1password.com/linux/debian/${ARCH} stable main" |
| 77 | if [[ ! -f "$SOURCES" ]] || ! grep -qxF "$DESIRED_SRC" "$SOURCES"; then |
| 78 | run "printf '%s\n' '$DESIRED_SRC' > '$SOURCES'" |
| 79 | fi |
| 80 | |
| 81 | log "Installing debsig policy (verifies package signatures on install)..." |
| 82 | run "install -d -m 0755 '$DEBSIG_POLICY_DIR' '$DEBSIG_KEYRING_DIR'" |
| 83 | if [[ ! -s "${DEBSIG_POLICY_DIR}/1password.pol" ]]; then |
| 84 | run "curl -fsSL https://downloads.1password.com/linux/debian/debsig/1password.pol -o '${DEBSIG_POLICY_DIR}/1password.pol'" |
| 85 | fi |
| 86 | if [[ ! -s "${DEBSIG_KEYRING_DIR}/debsig.gpg" ]]; then |
| 87 | run "curl -fsSL https://downloads.1password.com/linux/keys/1password.asc | gpg --dearmor -o '${DEBSIG_KEYRING_DIR}/debsig.gpg'" |
| 88 | fi |
| 89 | |
| 90 | log "Installing 1Password..." |
| 91 | run "apt-get update -qq" |
| 92 | run "apt-get install -y 1password" |
| 93 | |
| 94 | if (( ! DRY_RUN )) && command -v 1password >/dev/null 2>&1; then |
| 95 | log "1Password binary available at: $(command -v 1password)" |
| 96 | fi |
| 97 | log "Done." |
| 98 |
| 1 | #!/usr/bin/env bash |
| 2 | # install-bruno.sh — Install Bruno API client from the official APT repository. |
| 3 | # Hardened: arch-aware, signed-by keyring with retries (keyserver flake), idempotent, |
| 4 | # clean desktop entry, --dry-run. |
| 5 | |
| 6 | set -euo pipefail |
| 7 | IFS=$'\n\t' |
| 8 | |
| 9 | readonly SCRIPT_NAME="${0##*/}" |
| 10 | DRY_RUN=0 |
| 11 | |
| 12 | usage() { |
| 13 | cat <<EOF |
| 14 | Usage: sudo $SCRIPT_NAME [--dry-run] [--help] |
| 15 | |
| 16 | Configures the official Bruno APT repository (signed-by keyring fetched from |
| 17 | keyserver.ubuntu.com with retries) and installs Bruno. Idempotent: safe to |
| 18 | re-run. |
| 19 | |
| 20 | Options: |
| 21 | --dry-run Print actions without executing. |
| 22 | --help, -h Show this help. |
| 23 | EOF |
| 24 | } |
| 25 | |
| 26 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 27 | warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; } |
| 28 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 29 | run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; } |
| 30 | trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR |
| 31 | |
| 32 | while (( $# )); do |
| 33 | case "$1" in |
| 34 | --dry-run) DRY_RUN=1 ;; |
| 35 | -h|--help) usage; exit 0 ;; |
| 36 | *) die "Unknown argument: $1 (try --help)" ;; |
| 37 | esac |
| 38 | shift |
| 39 | done |
| 40 | |
| 41 | (( EUID == 0 )) || die "Must run as root. Try: sudo $SCRIPT_NAME" |
| 42 | |
| 43 | [[ -r /etc/os-release ]] || die "/etc/os-release not found." |
| 44 | # shellcheck disable=SC1091 |
| 45 | . /etc/os-release |
| 46 | case "${ID:-}:${ID_LIKE:-}" in |
| 47 | *ubuntu*|*debian*) : ;; |
| 48 | *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;; |
| 49 | esac |
| 50 | |
| 51 | ARCH="$(dpkg --print-architecture)" |
| 52 | [[ "$ARCH" == "amd64" ]] || die "Bruno APT repo only ships amd64 (detected: $ARCH)." |
| 53 | log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH" |
| 54 | |
| 55 | export DEBIAN_FRONTEND=noninteractive |
| 56 | |
| 57 | KEYRING=/etc/apt/keyrings/bruno.gpg |
| 58 | SOURCES=/etc/apt/sources.list.d/bruno.list |
| 59 | KEY_ID="9FA6017ECABE0266" |
| 60 | DESKTOP=/usr/share/applications/bruno.desktop |
| 61 | |
| 62 | log "Installing prerequisites..." |
| 63 | run "apt-get update -qq" |
| 64 | run "apt-get install -y curl gpg ca-certificates" |
| 65 | |
| 66 | log "Configuring Bruno APT repository..." |
| 67 | run "install -d -m 0755 /etc/apt/keyrings" |
| 68 | |
| 69 | if [[ ! -s "$KEYRING" ]]; then |
| 70 | # keyserver.ubuntu.com is occasionally flaky — retry a few times. |
| 71 | ok=0 |
| 72 | for attempt in 1 2 3 4 5; do |
| 73 | if (( DRY_RUN )); then |
| 74 | printf ' DRY-RUN: fetch key 0x%s (attempt %d)\n' "$KEY_ID" "$attempt" |
| 75 | ok=1 |
| 76 | break |
| 77 | fi |
| 78 | if curl -fsSL --max-time 30 "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x${KEY_ID}" \ |
| 79 | | gpg --dearmor -o "$KEYRING" 2>/dev/null && [[ -s "$KEYRING" ]]; then |
| 80 | ok=1 |
| 81 | break |
| 82 | fi |
| 83 | warn "Key fetch failed (attempt $attempt/5); retrying in $((attempt*2))s..." |
| 84 | sleep "$((attempt*2))" |
| 85 | done |
| 86 | (( ok )) || die "Could not retrieve Bruno signing key after 5 attempts." |
| 87 | run "chmod 0644 '$KEYRING'" |
| 88 | fi |
| 89 | |
| 90 | DESIRED_SRC="deb [arch=${ARCH} signed-by=${KEYRING}] http://debian.usebruno.com/ bruno stable" |
| 91 | if [[ ! -f "$SOURCES" ]] || ! grep -qxF "$DESIRED_SRC" "$SOURCES"; then |
| 92 | run "printf '%s\n' '$DESIRED_SRC' > '$SOURCES'" |
| 93 | fi |
| 94 | |
| 95 | log "Installing Bruno..." |
| 96 | run "apt-get update -qq" |
| 97 | run "apt-get install -y bruno" |
| 98 | |
| 99 | log "Writing desktop entry..." |
| 100 | if (( DRY_RUN )); then |
| 101 | printf ' DRY-RUN: write %s\n' "$DESKTOP" |
| 102 | else |
| 103 | cat >"$DESKTOP" <<'EOF' |
| 104 | [Desktop Entry] |
| 105 | Name=Bruno |
| 106 | Comment=Open-source API Client |
| 107 | Exec=bruno %U |
| 108 | Terminal=false |
| 109 | Type=Application |
| 110 | Icon=bruno |
| 111 | Categories=Development;Utility; |
| 112 | StartupNotify=true |
| 113 | EOF |
| 114 | chmod 0644 "$DESKTOP" |
| 115 | fi |
| 116 | |
| 117 | log "Done. Bruno installed." |
| 118 |
| 1 | #!/usr/bin/env bash |
| 2 | # |
| 3 | # install-chrome.sh — Native Google Chrome Installer for Ubuntu/Debian |
| 4 | # Downloads and installs the latest official stable release without Snap. |
| 5 | # |
| 6 | # Usage: |
| 7 | # sudo ./install-chrome.sh |
| 8 | |
| 9 | set -euo pipefail |
| 10 | IFS=$'\n\t' |
| 11 | |
| 12 | # Ensure the script is run as root |
| 13 | if (( EUID != 0 )); then |
| 14 | echo "[ERROR] This script must be run as root (via sudo)." >&2 |
| 15 | exit 1 |
| 16 | fi |
| 17 | |
| 18 | echo "[1/4] Installing necessary prerequisites..." |
| 19 | apt-get update -y |
| 20 | apt-get install -y curl gnupg2 ca-certificates |
| 21 | |
| 22 | echo "[2/4] Setting up Google's official repository keys..." |
| 23 | # Create directory for Third-Party APT keys if it doesn't exist |
| 24 | install -d -m 0755 /etc/apt/keyrings |
| 25 | |
| 26 | # Fetch the official signing key and save it securely |
| 27 | curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | \ |
| 28 | gpg --dearmor --yes -o /etc/apt/keyrings/google-chrome.gpg |
| 29 | |
| 30 | # Configure the APT repository to strictly use that signing key |
| 31 | echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" \ |
| 32 | > /etc/apt/sources.list.d/google-chrome.list |
| 33 | |
| 34 | echo "[3/4] Fetching package lists and installing Google Chrome..." |
| 35 | apt-get update -y |
| 36 | apt-get install -y google-chrome-stable |
| 37 | |
| 38 | echo "[4/4] Verifying installation..." |
| 39 | if command -v google-chrome &>/dev/null; then |
| 40 | echo "------------------------------------------------------" |
| 41 | echo "SUCCESS: Google Chrome has been installed successfully!" |
| 42 | echo "Version: $(google-chrome --version)" |
| 43 | echo "------------------------------------------------------" |
| 44 | else |
| 45 | echo "[ERROR] Google Chrome installation could not be verified." >&2 |
| 46 | exit 1 |
| 47 | fi |
| 1 | #!/usr/bin/env bash |
| 2 | # install-discord.sh — Install Discord from the latest official Linux .deb. |
| 3 | # Hardened: official latest endpoint, idempotent, --dry-run. |
| 4 | |
| 5 | set -euo pipefail |
| 6 | IFS=$'\n\t' |
| 7 | |
| 8 | readonly SCRIPT_NAME="${0##*/}" |
| 9 | DRY_RUN=0 |
| 10 | DOWNLOAD_URL="https://discord.com/api/download?platform=linux&format=deb" |
| 11 | |
| 12 | usage() { |
| 13 | cat <<EOF |
| 14 | Usage: sudo $SCRIPT_NAME [--dry-run] [--help] |
| 15 | |
| 16 | Downloads Discord from Discord's official latest Linux .deb endpoint and |
| 17 | installs it with apt-get. |
| 18 | |
| 19 | Options: |
| 20 | --dry-run Print actions without executing. |
| 21 | --help, -h Show this help. |
| 22 | EOF |
| 23 | } |
| 24 | |
| 25 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 26 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 27 | run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; } |
| 28 | trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR |
| 29 | |
| 30 | while (( $# )); do |
| 31 | case "$1" in |
| 32 | --dry-run) DRY_RUN=1 ;; |
| 33 | -h|--help) usage; exit 0 ;; |
| 34 | *) die "Unknown argument: $1 (try --help)" ;; |
| 35 | esac |
| 36 | shift |
| 37 | done |
| 38 | |
| 39 | (( EUID == 0 )) || die "Must run as root. Try: sudo $SCRIPT_NAME" |
| 40 | |
| 41 | [[ -r /etc/os-release ]] || die "/etc/os-release not found." |
| 42 | # shellcheck disable=SC1091 |
| 43 | . /etc/os-release |
| 44 | case "${ID:-}:${ID_LIKE:-}" in |
| 45 | *ubuntu*|*debian*) : ;; |
| 46 | *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;; |
| 47 | esac |
| 48 | |
| 49 | ARCH="$(dpkg --print-architecture)" |
| 50 | [[ "$ARCH" == "amd64" ]] || die "Discord's official Linux .deb targets amd64 only (detected: $ARCH)." |
| 51 | log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH" |
| 52 | |
| 53 | export DEBIAN_FRONTEND=noninteractive |
| 54 | log "Installing prerequisites..." |
| 55 | run "apt-get update -qq" |
| 56 | run "apt-get install -y curl ca-certificates" |
| 57 | |
| 58 | STAGE="$(mktemp -d -t discord.XXXXXX)" |
| 59 | trap 'rm -rf "$STAGE"' EXIT |
| 60 | DEB="$STAGE/discord.deb" |
| 61 | |
| 62 | log "Downloading latest Discord .deb..." |
| 63 | run "curl -fL -o '$DEB' '$DOWNLOAD_URL'" |
| 64 | |
| 65 | if (( DRY_RUN )); then |
| 66 | printf ' DRY-RUN: apt-get install -y %s\n' "$DEB" |
| 67 | log "Done." |
| 68 | exit 0 |
| 69 | fi |
| 70 | |
| 71 | log "Installing Discord..." |
| 72 | run "apt-get install -y '$DEB'" |
| 73 | |
| 74 | if command -v discord >/dev/null 2>&1; then |
| 75 | log "Installed: $(discord --version 2>/dev/null || dpkg-query -W -f='${Version}' discord 2>/dev/null || echo Discord)" |
| 76 | else |
| 77 | log "Installed: $(dpkg-query -W -f='${Version}' discord 2>/dev/null || echo Discord)" |
| 78 | fi |
| 79 | log "Done." |
| 80 |
| 1 | #!/usr/bin/env bash |
| 2 | # install-drawio.sh — Install draw.io Desktop from the latest official GitHub release. |
| 3 | # Hardened: arch-aware, GitHub API token support, SHA-256 verification from the |
| 4 | # release asset digest, idempotent, --dry-run. |
| 5 | |
| 6 | set -euo pipefail |
| 7 | IFS=$'\n\t' |
| 8 | |
| 9 | readonly SCRIPT_NAME="${0##*/}" |
| 10 | DRY_RUN=0 |
| 11 | REPO="jgraph/drawio-desktop" |
| 12 | |
| 13 | usage() { |
| 14 | cat <<EOF |
| 15 | Usage: sudo $SCRIPT_NAME [--dry-run] [--help] |
| 16 | |
| 17 | Resolves the latest release of jgraph/drawio-desktop, downloads the official |
| 18 | Linux .deb package for your architecture, verifies its SHA-256 against the |
| 19 | GitHub release asset digest when available, and installs it with apt-get. |
| 20 | |
| 21 | If \$GITHUB_TOKEN is set in the environment, it is used to authenticate the |
| 22 | GitHub API request (avoids rate limits). |
| 23 | |
| 24 | Options: |
| 25 | --dry-run Print actions without executing. |
| 26 | --help, -h Show this help. |
| 27 | EOF |
| 28 | } |
| 29 | |
| 30 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 31 | warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; } |
| 32 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 33 | run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; } |
| 34 | trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR |
| 35 | |
| 36 | while (( $# )); do |
| 37 | case "$1" in |
| 38 | --dry-run) DRY_RUN=1 ;; |
| 39 | -h|--help) usage; exit 0 ;; |
| 40 | *) die "Unknown argument: $1 (try --help)" ;; |
| 41 | esac |
| 42 | shift |
| 43 | done |
| 44 | |
| 45 | (( EUID == 0 )) || die "Must run as root. Try: sudo $SCRIPT_NAME" |
| 46 | |
| 47 | [[ -r /etc/os-release ]] || die "/etc/os-release not found." |
| 48 | # shellcheck disable=SC1091 |
| 49 | . /etc/os-release |
| 50 | case "${ID:-}:${ID_LIKE:-}" in |
| 51 | *ubuntu*|*debian*) : ;; |
| 52 | *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;; |
| 53 | esac |
| 54 | |
| 55 | ARCH="$(dpkg --print-architecture)" |
| 56 | case "$ARCH" in |
| 57 | amd64|arm64) : ;; |
| 58 | *) die "draw.io Desktop publishes Linux .deb assets for amd64 and arm64 only (detected: $ARCH)." ;; |
| 59 | esac |
| 60 | log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH" |
| 61 | |
| 62 | export DEBIAN_FRONTEND=noninteractive |
| 63 | log "Installing prerequisites..." |
| 64 | run "apt-get update -qq" |
| 65 | run "apt-get install -y curl jq ca-certificates" |
| 66 | |
| 67 | GH_HDRS=(-H "Accept: application/vnd.github+json") |
| 68 | [[ -n "${GITHUB_TOKEN:-}" ]] && GH_HDRS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}") |
| 69 | |
| 70 | log "Querying GitHub API for latest release of $REPO..." |
| 71 | RELEASE_JSON="$(curl -fsSL "${GH_HDRS[@]}" "https://api.github.com/repos/${REPO}/releases/latest")" |
| 72 | TAG="$(jq -r '.tag_name // empty' <<<"$RELEASE_JSON")" |
| 73 | [[ -n "$TAG" ]] || die "Could not parse latest release tag (rate limited? set GITHUB_TOKEN)." |
| 74 | log "Latest release: $TAG" |
| 75 | |
| 76 | ASSET_REGEX="^drawio-${ARCH}-[^/]+\\.deb$" |
| 77 | DOWNLOAD_URL="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | .browser_download_url' <<<"$RELEASE_JSON" | head -n1)" |
| 78 | ASSET_NAME="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | .name' <<<"$RELEASE_JSON" | head -n1)" |
| 79 | EXPECTED_SHA="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | (.digest // "") | sub("^sha256:"; "")' <<<"$RELEASE_JSON" | head -n1)" |
| 80 | [[ "$DOWNLOAD_URL" =~ ^https:// ]] || die "No $ARCH .deb asset found in latest draw.io Desktop release." |
| 81 | |
| 82 | STAGE="$(mktemp -d -t drawio.XXXXXX)" |
| 83 | trap 'rm -rf "$STAGE"' EXIT |
| 84 | DEB="$STAGE/$ASSET_NAME" |
| 85 | |
| 86 | log "Downloading $ASSET_NAME..." |
| 87 | run "curl -fsSL -o '$DEB' '$DOWNLOAD_URL'" |
| 88 | |
| 89 | if (( DRY_RUN )); then |
| 90 | [[ -n "$EXPECTED_SHA" && "$EXPECTED_SHA" != "null" ]] \ |
| 91 | && printf ' DRY-RUN: verify SHA-256 %s\n' "$EXPECTED_SHA" \ |
| 92 | || printf ' DRY-RUN: skip SHA-256 verification; no GitHub asset digest found\n' |
| 93 | printf ' DRY-RUN: apt-get install -y %s\n' "$DEB" |
| 94 | log "Done." |
| 95 | exit 0 |
| 96 | fi |
| 97 | |
| 98 | if [[ -n "$EXPECTED_SHA" && "$EXPECTED_SHA" != "null" ]]; then |
| 99 | log "Verifying SHA-256..." |
| 100 | ACTUAL_SHA="$(sha256sum "$DEB" | awk '{print $1}')" |
| 101 | [[ "$EXPECTED_SHA" == "$ACTUAL_SHA" ]] || die "SHA-256 mismatch: expected=$EXPECTED_SHA actual=$ACTUAL_SHA" |
| 102 | log "SHA-256 ok." |
| 103 | else |
| 104 | warn "No SHA-256 asset digest in GitHub release metadata; skipping verification." |
| 105 | fi |
| 106 | |
| 107 | log "Installing draw.io Desktop..." |
| 108 | run "apt-get install -y '$DEB'" |
| 109 | |
| 110 | if (( ! DRY_RUN )) && command -v drawio >/dev/null 2>&1; then |
| 111 | log "Installed: $(drawio --version 2>/dev/null || echo "$TAG")" |
| 112 | fi |
| 113 | log "Done." |
| 114 |
| 1 | #!/usr/bin/env bash |
| 2 | # install-espanso.sh — Install Espanso (text expander) with Wayland or X11 build. |
| 3 | # Auto-detects session type; falls back to Wayland if unknown. |
| 4 | # Hardened: handles apt temp file permissions, package collisions, and ghost processes. |
| 5 | |
| 6 | set -euo pipefail |
| 7 | IFS=$'\n\t' |
| 8 | |
| 9 | readonly SCRIPT_NAME="${0##*/}" |
| 10 | DRY_RUN=0 |
| 11 | FORCE_VARIANT="" # "wayland" | "x11" |
| 12 | |
| 13 | usage() { |
| 14 | cat <<EOF |
| 15 | Usage: sudo $SCRIPT_NAME [--dry-run] [--wayland|--x11] [--help] |
| 16 | |
| 17 | Installs Espanso from the latest GitHub release. Auto-detects whether to use |
| 18 | the Wayland or X11 build based on the invoking user (SUDO_USER). |
| 19 | The systemd-user service is registered for that user, not root. |
| 20 | |
| 21 | Options: |
| 22 | --wayland Force the Wayland build. |
| 23 | --x11 Force the X11 build. |
| 24 | --dry-run Print actions without executing. |
| 25 | --help, -h Show this help. |
| 26 | EOF |
| 27 | } |
| 28 | |
| 29 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 30 | warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; } |
| 31 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 32 | run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; } |
| 33 | trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR |
| 34 | |
| 35 | while (( $# )); do |
| 36 | case "$1" in |
| 37 | --dry-run) DRY_RUN=1 ;; |
| 38 | --wayland) FORCE_VARIANT=wayland ;; |
| 39 | --x11) FORCE_VARIANT=x11 ;; |
| 40 | -h|--help) usage; exit 0 ;; |
| 41 | *) die "Unknown argument: $1 (try --help)" ;; |
| 42 | esac |
| 43 | shift |
| 44 | done |
| 45 | |
| 46 | (( EUID == 0 )) || die "Must run as root. Try: sudo $SCRIPT_NAME" |
| 47 | |
| 48 | [[ -r /etc/os-release ]] || die "/etc/os-release not found." |
| 49 | # shellcheck disable=SC1091 |
| 50 | . /etc/os-release |
| 51 | case "${ID:-}:${ID_LIKE:-}" in |
| 52 | *ubuntu*|*debian*) : ;; |
| 53 | *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;; |
| 54 | esac |
| 55 | |
| 56 | ARCH="$(dpkg --print-architecture)" |
| 57 | [[ "$ARCH" == "amd64" ]] || die "Espanso .deb is published for amd64 only (detected: $ARCH)." |
| 58 | |
| 59 | ACTUAL_USER="${SUDO_USER:-${USER:-}}" |
| 60 | [[ -n "$ACTUAL_USER" && "$ACTUAL_USER" != "root" ]] || die "Run via sudo as a regular user; cannot register the service as root." |
| 61 | USER_ID="$(id -u "$ACTUAL_USER")" |
| 62 | |
| 63 | # --- FIXED: Better session detection logic using loginctl --- |
| 64 | if [[ -n "$FORCE_VARIANT" ]]; then |
| 65 | VARIANT="$FORCE_VARIANT" |
| 66 | else |
| 67 | # 1. Try systemd loginctl (most reliable) |
| 68 | SESSION_ID=$(loginctl list-sessions 2>/dev/null | grep " $ACTUAL_USER " | awk '{print $1}' | head -n 1 || true) |
| 69 | if [[ -n "$SESSION_ID" ]]; then |
| 70 | SESSION_TYPE=$(loginctl show-session "$SESSION_ID" -p Type --value 2>/dev/null || true) |
| 71 | else |
| 72 | SESSION_TYPE="" |
| 73 | fi |
| 74 | |
| 75 | # 2. If loginctl fails, check for active Wayland/X11 socket files |
| 76 | if [[ -z "$SESSION_TYPE" || "$SESSION_TYPE" == "tty" ]]; then |
| 77 | if sudo -u "$ACTUAL_USER" test -S "/run/user/$USER_ID/wayland-0"; then |
| 78 | SESSION_TYPE="wayland" |
| 79 | elif sudo -u "$ACTUAL_USER" test -S "/tmp/.X11-unix/X0"; then |
| 80 | SESSION_TYPE="x11" |
| 81 | else |
| 82 | SESSION_TYPE="wayland" # Default fallback for modern Ubuntu |
| 83 | fi |
| 84 | fi |
| 85 | |
| 86 | case "$SESSION_TYPE" in |
| 87 | wayland) VARIANT=wayland ;; |
| 88 | x11) VARIANT=x11 ;; |
| 89 | *) warn "Unknown session type='$SESSION_TYPE'; defaulting to wayland."; VARIANT=wayland ;; |
| 90 | esac |
| 91 | fi |
| 92 | log "Detected: ${PRETTY_NAME:-unknown}, user: $ACTUAL_USER, session: $VARIANT" |
| 93 | |
| 94 | export DEBIAN_FRONTEND=noninteractive |
| 95 | |
| 96 | # --- FIXED: Use a subdirectory to avoid apt "unsandboxed" permission warnings --- |
| 97 | WORK_DIR="$(mktemp -d /tmp/espanso-install.XXXXXX)" |
| 98 | chmod 755 "$WORK_DIR" |
| 99 | TEMP_DEB="$WORK_DIR/espanso.deb" |
| 100 | trap 'rm -rf "$WORK_DIR"' EXIT |
| 101 | |
| 102 | DEB_NAME="espanso-debian-${VARIANT}-amd64.deb" |
| 103 | URL="https://github.com/espanso/espanso/releases/latest/download/${DEB_NAME}" |
| 104 | |
| 105 | log "Installing prerequisites..." |
| 106 | run "apt-get update -qq" |
| 107 | run "apt-get install -y wget libcap2-bin psmisc" |
| 108 | |
| 109 | # --- FIXED: Use wget with progress bar instead of silent mode --- |
| 110 | log "Downloading ${DEB_NAME}..." |
| 111 | run "wget -q --show-progress -O '$TEMP_DEB' '$URL'" |
| 112 | |
| 113 | log "Removing conflicting Espanso packages (if any)..." |
| 114 | run "apt-get remove -y espanso espanso-wayland >/dev/null 2>&1 || true" |
| 115 | |
| 116 | log "Installing package..." |
| 117 | run "apt-get install -y '$TEMP_DEB'" |
| 118 | |
| 119 | ESPANSO_BIN="$(command -v espanso || true)" |
| 120 | [[ -x "$ESPANSO_BIN" ]] || die "espanso binary not found after install." |
| 121 | |
| 122 | if [[ "$VARIANT" == "wayland" ]]; then |
| 123 | log "Setting CAP_DAC_OVERRIDE on $ESPANSO_BIN..." |
| 124 | run "setcap 'cap_dac_override+p' '$ESPANSO_BIN'" |
| 125 | fi |
| 126 | |
| 127 | log "Clearing ghost processes to prevent start timeouts..." |
| 128 | run "sudo -u '$ACTUAL_USER' killall espanso 2>/dev/null || true" |
| 129 | |
| 130 | log "Registering & starting espanso service for $ACTUAL_USER..." |
| 131 | if (( DRY_RUN )); then |
| 132 | printf ' DRY-RUN: sudo -u %s XDG_RUNTIME_DIR=/run/user/%s espanso service register || true\n' "$ACTUAL_USER" "$USER_ID" |
| 133 | printf ' DRY-RUN: sudo -u %s XDG_RUNTIME_DIR=/run/user/%s espanso start || true\n' "$ACTUAL_USER" "$USER_ID" |
| 134 | else |
| 135 | sudo -u "$ACTUAL_USER" XDG_RUNTIME_DIR="/run/user/$USER_ID" espanso service register || true |
| 136 | sudo -u "$ACTUAL_USER" XDG_RUNTIME_DIR="/run/user/$USER_ID" espanso restart || \ |
| 137 | sudo -u "$ACTUAL_USER" XDG_RUNTIME_DIR="/run/user/$USER_ID" espanso start || true |
| 138 | fi |
| 139 | |
| 140 | log "Done. Espanso ($VARIANT) installed and started for $ACTUAL_USER." |
| 141 | if [[ "$VARIANT" == "wayland" ]]; then |
| 142 | log "Wayland note: non-US keyboards must set the layout in ~/.config/espanso/config/default.yml" |
| 143 | fi |
| 1 | #!/usr/bin/env bash |
| 2 | # install-firefox.sh — Install Firefox from Mozilla's official APT repository. |
| 3 | # Removes the Snap transition package and pins Mozilla as the source of truth. |
| 4 | # Best-effort: pins the APT launcher at GNOME Dock position 2. |
| 5 | # Hardened: distro-detect, idempotent, signed-by keyring, ERR trap, --dry-run. |
| 6 | |
| 7 | set -euo pipefail |
| 8 | IFS=$'\n\t' |
| 9 | |
| 10 | readonly SCRIPT_NAME="${0##*/}" |
| 11 | DRY_RUN=0 |
| 12 | ASSUME_YES=0 |
| 13 | |
| 14 | usage() { |
| 15 | cat <<EOF |
| 16 | Usage: sudo $SCRIPT_NAME [--dry-run] [--yes] [--help] |
| 17 | |
| 18 | Removes Firefox Snap (and Ubuntu's transitional wrapper), configures the |
| 19 | official Mozilla APT repository (signed-by keyring + APT pin), then installs |
| 20 | Firefox so it auto-updates from Mozilla. On GNOME, Firefox is pinned at Dock |
| 21 | position 2 when a desktop user session is available. |
| 22 | |
| 23 | Options: |
| 24 | --dry-run Print the actions without executing them. |
| 25 | --yes, -y Pass -y to apt for non-interactive install. |
| 26 | --help, -h Show this help. |
| 27 | EOF |
| 28 | } |
| 29 | |
| 30 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 31 | warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; } |
| 32 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 33 | run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; } |
| 34 | |
| 35 | on_err() { local rc=$? line=$1; printf '\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n' "${SCRIPT_NAME%.sh}" "$line" "$rc" >&2; } |
| 36 | trap 'on_err $LINENO' ERR |
| 37 | |
| 38 | while (( $# )); do |
| 39 | case "$1" in |
| 40 | --dry-run) DRY_RUN=1 ;; |
| 41 | -y|--yes) ASSUME_YES=1 ;; |
| 42 | -h|--help) usage; exit 0 ;; |
| 43 | *) die "Unknown argument: $1 (try --help)" ;; |
| 44 | esac |
| 45 | shift |
| 46 | done |
| 47 | |
| 48 | (( EUID == 0 )) || die "Must run as root. Try: sudo $SCRIPT_NAME" |
| 49 | |
| 50 | [[ -r /etc/os-release ]] || die "/etc/os-release not found; cannot detect distro." |
| 51 | # shellcheck disable=SC1091 |
| 52 | . /etc/os-release |
| 53 | case "${ID:-}:${ID_LIKE:-}" in |
| 54 | *ubuntu*|*debian*) : ;; |
| 55 | *) die "Unsupported distro: ${PRETTY_NAME:-unknown}. Requires Debian/Ubuntu." ;; |
| 56 | esac |
| 57 | log "Detected: ${PRETTY_NAME:-unknown} (codename: ${VERSION_CODENAME:-?})" |
| 58 | |
| 59 | APT_YES=() |
| 60 | (( ASSUME_YES )) && APT_YES=(-y) || APT_YES=(-y) # always -y for safety in scripted use |
| 61 | export DEBIAN_FRONTEND=noninteractive |
| 62 | |
| 63 | KEYRING=/etc/apt/keyrings/packages.mozilla.org.asc |
| 64 | SOURCES=/etc/apt/sources.list.d/mozilla.list |
| 65 | PIN=/etc/apt/preferences.d/mozilla |
| 66 | |
| 67 | log "Removing Firefox Snap and Ubuntu's transitional wrapper (if present)..." |
| 68 | if command -v snap >/dev/null 2>&1; then |
| 69 | run "snap disable firefox >/dev/null 2>&1 || true" |
| 70 | run "snap remove --purge firefox >/dev/null 2>&1 || true" |
| 71 | fi |
| 72 | run "apt-get remove --purge ${APT_YES[*]} firefox >/dev/null 2>&1 || true" |
| 73 | run "rm -f /usr/bin/firefox" |
| 74 | |
| 75 | log "Installing prerequisites (wget, gpg, ca-certificates)..." |
| 76 | run "apt-get update -qq" |
| 77 | run "apt-get install ${APT_YES[*]} wget gpg ca-certificates" |
| 78 | |
| 79 | log "Configuring Mozilla APT repository..." |
| 80 | run "install -d -m 0755 /etc/apt/keyrings" |
| 81 | if [[ ! -s "$KEYRING" ]]; then |
| 82 | run "wget -qO '$KEYRING' https://packages.mozilla.org/apt/repo-signing-key.gpg" |
| 83 | run "chmod 0644 '$KEYRING'" |
| 84 | else |
| 85 | log "Keyring already present at $KEYRING (skipping download)." |
| 86 | fi |
| 87 | |
| 88 | # Verify key fingerprint matches Mozilla's published fingerprint |
| 89 | EXPECTED_FPR="35BAA0B33E9EB396F59CA838C0BA5CE6DC6315A3" |
| 90 | ACTUAL_FPR="$(gpg --show-keys --with-colons "$KEYRING" 2>/dev/null | awk -F: '/^fpr/ {print $10; exit}')" |
| 91 | if [[ "$ACTUAL_FPR" != "$EXPECTED_FPR" ]]; then |
| 92 | warn "Mozilla key fingerprint mismatch (expected $EXPECTED_FPR, got ${ACTUAL_FPR:-none}). Continuing, but verify manually." |
| 93 | else |
| 94 | log "Mozilla key fingerprint verified." |
| 95 | fi |
| 96 | |
| 97 | DESIRED_SRC='deb [signed-by=/etc/apt/keyrings/packages.mozilla.org.asc] https://packages.mozilla.org/apt mozilla main' |
| 98 | if [[ ! -f "$SOURCES" ]] || ! grep -qxF "$DESIRED_SRC" "$SOURCES"; then |
| 99 | run "printf '%s\n' '$DESIRED_SRC' > '$SOURCES'" |
| 100 | fi |
| 101 | |
| 102 | log "Pinning Mozilla repo to priority 1000..." |
| 103 | if [[ ! -f "$PIN" ]] || ! grep -q 'origin packages.mozilla.org' "$PIN"; then |
| 104 | if (( DRY_RUN )); then |
| 105 | printf ' DRY-RUN: write %s\n' "$PIN" |
| 106 | else |
| 107 | cat >"$PIN" <<'EOF' |
| 108 | Package: * |
| 109 | Pin: origin packages.mozilla.org |
| 110 | Pin-Priority: 1000 |
| 111 | EOF |
| 112 | fi |
| 113 | fi |
| 114 | |
| 115 | log "Installing Firefox from Mozilla repo..." |
| 116 | run "apt-get update -qq" |
| 117 | run "apt-get install ${APT_YES[*]} firefox" |
| 118 | |
| 119 | if (( ! DRY_RUN )) && command -v firefox >/dev/null 2>&1; then |
| 120 | log "Installed: $(firefox --version 2>/dev/null || echo 'firefox')" |
| 121 | fi |
| 122 | |
| 123 | # Replace the stale Snap launcher, remove duplicates, and place the Mozilla APT |
| 124 | # launcher after the first existing favorite. Dock changes are per-user, so run |
| 125 | # gsettings against the desktop user's active D-Bus session rather than root's. |
| 126 | DESKTOP_USER="${SUDO_USER:-}" |
| 127 | if (( DRY_RUN )); then |
| 128 | printf ' DRY-RUN: pin firefox.desktop at GNOME Dock position 2 when a desktop session is available\n' |
| 129 | elif [[ -z "$DESKTOP_USER" || "$DESKTOP_USER" == "root" ]]; then |
| 130 | warn "No sudo desktop user detected; skipping GNOME Dock pin." |
| 131 | else |
| 132 | DESKTOP_UID="$(id -u "$DESKTOP_USER" 2>/dev/null || true)" |
| 133 | RUNTIME_DIR="/run/user/$DESKTOP_UID" |
| 134 | DBUS_ADDRESS="unix:path=$RUNTIME_DIR/bus" |
| 135 | |
| 136 | if [[ -n "$DESKTOP_UID" && -S "$RUNTIME_DIR/bus" ]] && |
| 137 | command -v gsettings >/dev/null 2>&1 && |
| 138 | sudo -u "$DESKTOP_USER" XDG_RUNTIME_DIR="$RUNTIME_DIR" DBUS_SESSION_BUS_ADDRESS="$DBUS_ADDRESS" \ |
| 139 | gsettings list-keys org.gnome.shell >/dev/null 2>&1; then |
| 140 | log "Pinning Firefox at GNOME Dock position 2..." |
| 141 | CURRENT_FAVS="$(sudo -u "$DESKTOP_USER" XDG_RUNTIME_DIR="$RUNTIME_DIR" DBUS_SESSION_BUS_ADDRESS="$DBUS_ADDRESS" \ |
| 142 | gsettings get org.gnome.shell favorite-apps 2>/dev/null || echo '[]')" |
| 143 | |
| 144 | FAVORITES=() |
| 145 | while IFS= read -r favorite; do |
| 146 | favorite="${favorite#\'}" |
| 147 | favorite="${favorite%\'}" |
| 148 | case "$favorite" in |
| 149 | firefox.desktop|firefox_firefox.desktop) ;; |
| 150 | *) FAVORITES+=("$favorite") ;; |
| 151 | esac |
| 152 | done < <(printf '%s\n' "$CURRENT_FAVS" | grep -o "'[^']*'" || true) |
| 153 | |
| 154 | ORDERED_FAVORITES=() |
| 155 | if (( ${#FAVORITES[@]} == 0 )); then |
| 156 | ORDERED_FAVORITES=(firefox.desktop) |
| 157 | else |
| 158 | ORDERED_FAVORITES=("${FAVORITES[0]}" firefox.desktop "${FAVORITES[@]:1}") |
| 159 | fi |
| 160 | |
| 161 | NEW_FAVS="[" |
| 162 | separator="" |
| 163 | for favorite in "${ORDERED_FAVORITES[@]}"; do |
| 164 | favorite="${favorite//\\/\\\\}" |
| 165 | favorite="${favorite//\'/\\\'}" |
| 166 | NEW_FAVS+="${separator}'${favorite}'" |
| 167 | separator=", " |
| 168 | done |
| 169 | NEW_FAVS+="]" |
| 170 | |
| 171 | sudo -u "$DESKTOP_USER" XDG_RUNTIME_DIR="$RUNTIME_DIR" DBUS_SESSION_BUS_ADDRESS="$DBUS_ADDRESS" \ |
| 172 | gsettings set org.gnome.shell favorite-apps "$NEW_FAVS" || warn "GNOME Dock pin failed (non-fatal)." |
| 173 | else |
| 174 | log "GNOME not detected (or no active desktop D-Bus session); skipping Dock pin." |
| 175 | fi |
| 176 | fi |
| 177 | |
| 178 | log "Done. Firefox installed from Mozilla APT and will auto-update." |
| 179 |
| 1 | #!/usr/bin/env bash |
| 2 | |
| 3 | set -euo pipefail |
| 4 | IFS=$'\n\t' |
| 5 | |
| 6 | readonly SCRIPT_NAME="${0##*/}" |
| 7 | readonly FONT_BASE_DIR="$HOME/.local/share/fonts" |
| 8 | readonly TMP_DIR="$(mktemp -d)" |
| 9 | |
| 10 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 11 | warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; } |
| 12 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 13 | |
| 14 | cleanup() { |
| 15 | rm -rf "$TMP_DIR" |
| 16 | } |
| 17 | trap cleanup EXIT |
| 18 | |
| 19 | if (( EUID == 0 )); then |
| 20 | die "Do not run as root. Run as your normal desktop user so fonts install under $HOME." |
| 21 | fi |
| 22 | |
| 23 | for tool in curl unzip fc-cache find; do |
| 24 | command -v "$tool" >/dev/null 2>&1 || die "Missing required tool: $tool" |
| 25 | done |
| 26 | |
| 27 | latest_nerd_font_url() { |
| 28 | local asset_name="$1" |
| 29 | local url |
| 30 | |
| 31 | url="$( |
| 32 | curl -fsSL https://api.github.com/repos/ryanoasis/nerd-fonts/releases/latest \ |
| 33 | | grep "browser_download_url.*${asset_name}" \ |
| 34 | | cut -d '"' -f 4 \ |
| 35 | | head -n 1 \ |
| 36 | || true |
| 37 | )" |
| 38 | |
| 39 | [[ -n "$url" ]] || die "Could not find download URL for $asset_name. Check connectivity or GitHub API limits." |
| 40 | printf '%s' "$url" |
| 41 | } |
| 42 | |
| 43 | install_nerd_font() { |
| 44 | local display_name="$1" |
| 45 | local asset_name="$2" |
| 46 | local font_dir="$3" |
| 47 | local download_url="$4" |
| 48 | local zip_path="$TMP_DIR/${asset_name}.zip" |
| 49 | local extract_dir="$TMP_DIR/${asset_name}" |
| 50 | local installed_count |
| 51 | |
| 52 | if [[ -z "$download_url" ]]; then |
| 53 | log "Finding latest $display_name release..." |
| 54 | download_url="$(latest_nerd_font_url "${asset_name}.zip")" |
| 55 | fi |
| 56 | |
| 57 | log "Downloading $display_name..." |
| 58 | curl -fL --retry 3 --retry-delay 2 -o "$zip_path" "$download_url" |
| 59 | |
| 60 | log "Extracting $display_name..." |
| 61 | mkdir -p "$extract_dir" "$font_dir" |
| 62 | unzip -q -o "$zip_path" -d "$extract_dir" |
| 63 | |
| 64 | installed_count="$( |
| 65 | find "$extract_dir" -type f \( -name '*.ttf' -o -name '*.otf' \) \ |
| 66 | -exec cp {} "$font_dir/" \; \ |
| 67 | -printf '.' \ |
| 68 | | wc -c |
| 69 | )" |
| 70 | |
| 71 | [[ "$installed_count" -gt 0 ]] || die "No font files found in ${asset_name}.zip" |
| 72 | find "$font_dir" -type f \( -name '*.ttf' -o -name '*.otf' \) -exec chmod 0644 {} + |
| 73 | log "Installed $installed_count font files to $font_dir" |
| 74 | } |
| 75 | |
| 76 | mkdir -p "$FONT_BASE_DIR" |
| 77 | |
| 78 | install_nerd_font \ |
| 79 | "Ubuntu Sans Nerd Font" \ |
| 80 | "UbuntuSans" \ |
| 81 | "$FONT_BASE_DIR/UbuntuSans" \ |
| 82 | "" |
| 83 | |
| 84 | install_nerd_font \ |
| 85 | "JetBrains Mono Nerd Font" \ |
| 86 | "JetBrainsMono" \ |
| 87 | "$FONT_BASE_DIR/JetBrainsMonoNerdFont" \ |
| 88 | "" |
| 89 | |
| 90 | log "Updating font cache..." |
| 91 | fc-cache -f "$FONT_BASE_DIR" |
| 92 | |
| 93 | if command -v fc-match >/dev/null 2>&1; then |
| 94 | log "Resolved terminal font: $(fc-match 'JetBrainsMono Nerd Font Mono')" |
| 95 | fi |
| 96 | |
| 97 | log "Done. Configure your terminal to use JetBrainsMono Nerd Font Mono for Starship Powerline symbols." |
| 98 |
| 1 | #!/usr/bin/env bash |
| 2 | # install-ibus-pinyin.sh — Install IBus Intelligent Pinyin and add it |
| 3 | # to GNOME's Input Sources. Tuned for Ubuntu 26.04 (GNOME 50 / Wayland) but |
| 4 | # works on any modern Ubuntu/Debian GNOME desktop. |
| 5 | # |
| 6 | # Hardened: distro-detect, runs as the desktop user (not root) for gsettings, |
| 7 | # uses sudo only for apt steps, idempotent re-runs, ERR trap, --dry-run. |
| 8 | |
| 9 | set -euo pipefail |
| 10 | IFS=$'\n\t' |
| 11 | |
| 12 | readonly SCRIPT_NAME="${0##*/}" |
| 13 | DRY_RUN=0 |
| 14 | SKIP_GSETTINGS=0 |
| 15 | |
| 16 | usage() { |
| 17 | cat <<EOF |
| 18 | Usage: $SCRIPT_NAME [--dry-run] [--skip-gsettings] [--help] |
| 19 | |
| 20 | Installs ibus-libpinyin (plus Chinese language packs), refreshes IBus, and |
| 21 | registers 'Intelligent Pinyin' as a GNOME input source. Idempotent. |
| 22 | |
| 23 | Do NOT run with sudo: gsettings is per-user and must run as the desktop user. |
| 24 | The script invokes sudo internally only for apt-get steps. |
| 25 | |
| 26 | Options: |
| 27 | --skip-gsettings Install packages but don't touch GNOME input sources |
| 28 | (useful on non-GNOME desktops; configure manually afterwards). |
| 29 | --dry-run Print actions without executing. |
| 30 | --help, -h Show this help. |
| 31 | |
| 32 | Switch input methods at runtime with: Super + Space |
| 33 | EOF |
| 34 | } |
| 35 | |
| 36 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 37 | warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; } |
| 38 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 39 | trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR |
| 40 | |
| 41 | while (( $# )); do |
| 42 | case "$1" in |
| 43 | --dry-run) DRY_RUN=1 ;; |
| 44 | --skip-gsettings) SKIP_GSETTINGS=1 ;; |
| 45 | -h|--help) usage; exit 0 ;; |
| 46 | *) die "Unknown argument: $1 (try --help)" ;; |
| 47 | esac |
| 48 | shift |
| 49 | done |
| 50 | |
| 51 | # Resolve the desktop user. If invoked via sudo, gsettings must target SUDO_USER. |
| 52 | if (( EUID == 0 )); then |
| 53 | DESKTOP_USER="${SUDO_USER:-}" |
| 54 | [[ -n "$DESKTOP_USER" && "$DESKTOP_USER" != "root" ]] \ |
| 55 | || die "Run as a normal user (the script will call sudo itself for apt). gsettings can't run as root." |
| 56 | else |
| 57 | DESKTOP_USER="$USER" |
| 58 | fi |
| 59 | |
| 60 | # Look up the user's UID for DBus access when invoked through sudo. |
| 61 | USER_ENTRY="$(getent passwd "$DESKTOP_USER")" || die "User '$DESKTOP_USER' not found in passwd." |
| 62 | USER_ID="$(awk -F: '{print $3}' <<<"$USER_ENTRY")" |
| 63 | |
| 64 | [[ -r /etc/os-release ]] || die "/etc/os-release not found." |
| 65 | # shellcheck disable=SC1091 |
| 66 | . /etc/os-release |
| 67 | case "${ID:-}:${ID_LIKE:-}" in |
| 68 | *ubuntu*|*debian*) : ;; |
| 69 | *) die "Unsupported distro: ${PRETTY_NAME:-unknown}. Requires Debian/Ubuntu." ;; |
| 70 | esac |
| 71 | log "Detected: ${PRETTY_NAME:-unknown}, desktop user: $DESKTOP_USER" |
| 72 | |
| 73 | # Helpers: invoke a command as $DESKTOP_USER with a working DBus address. |
| 74 | query_as_user() { |
| 75 | if (( EUID == 0 )); then |
| 76 | sudo -u "$DESKTOP_USER" \ |
| 77 | DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$USER_ID/bus" \ |
| 78 | XDG_RUNTIME_DIR="/run/user/$USER_ID" \ |
| 79 | "$@" |
| 80 | else |
| 81 | "$@" |
| 82 | fi |
| 83 | } |
| 84 | |
| 85 | run_as_user() { |
| 86 | if (( DRY_RUN )); then |
| 87 | printf ' DRY-RUN (as %s):' "$DESKTOP_USER" |
| 88 | printf ' %q' "$@" |
| 89 | printf '\n' |
| 90 | else |
| 91 | query_as_user "$@" |
| 92 | fi |
| 93 | } |
| 94 | |
| 95 | # Helper: invoke a command with sudo when the caller is non-root. |
| 96 | sudo_run() { |
| 97 | if (( DRY_RUN )); then |
| 98 | printf ' DRY-RUN (sudo):' |
| 99 | printf ' %q' "$@" |
| 100 | printf '\n' |
| 101 | return 0 |
| 102 | fi |
| 103 | if (( EUID == 0 )); then "$@"; else sudo "$@"; fi |
| 104 | } |
| 105 | |
| 106 | export DEBIAN_FRONTEND=noninteractive |
| 107 | |
| 108 | log "Installing IBus Intelligent Pinyin + Chinese language packs..." |
| 109 | sudo_run apt-get update -qq |
| 110 | sudo_run apt-get install -y \ |
| 111 | ibus \ |
| 112 | ibus-libpinyin \ |
| 113 | language-pack-zh-hans \ |
| 114 | language-pack-gnome-zh-hans |
| 115 | |
| 116 | # Make the running daemon rebuild its engine registry. GNOME owns IBus through |
| 117 | # a systemd user service, so starting a competing daemon manually breaks the |
| 118 | # portal on Wayland. Other desktops can use the regular IBus restart command. |
| 119 | if command -v systemctl >/dev/null 2>&1 && \ |
| 120 | query_as_user systemctl --user is-active --quiet org.freedesktop.IBus.session.GNOME.service; then |
| 121 | log "Restarting GNOME's system-managed IBus service for $DESKTOP_USER..." |
| 122 | run_as_user systemctl --user restart org.freedesktop.IBus.session.GNOME.service || \ |
| 123 | warn "Could not restart GNOME's IBus service; log out and back in after installation." |
| 124 | elif command -v ibus >/dev/null 2>&1; then |
| 125 | log "Refreshing ibus-daemon for $DESKTOP_USER..." |
| 126 | run_as_user ibus restart || \ |
| 127 | warn "Could not restart ibus-daemon; log out and back in after installation." |
| 128 | fi |
| 129 | |
| 130 | if (( SKIP_GSETTINGS )); then |
| 131 | log "Skipping GNOME input-sources update (--skip-gsettings)." |
| 132 | log "Done. Add 'Chinese (Intelligent Pinyin)' manually under Settings → Keyboard → Input Sources." |
| 133 | exit 0 |
| 134 | fi |
| 135 | |
| 136 | # Query the key directly. Avoid a `list-schemas | grep -q` pipeline here: |
| 137 | # with `pipefail`, grep's early exit can make gsettings' SIGPIPE look like a |
| 138 | # missing schema and silently skip input-source registration. |
| 139 | if ! query_as_user gsettings get org.gnome.desktop.input-sources sources >/dev/null 2>&1; then |
| 140 | warn "GNOME schema org.gnome.desktop.input-sources not found; skipping gsettings update." |
| 141 | log "Done. Add 'Chinese (Intelligent Pinyin)' manually in your DE's input settings." |
| 142 | exit 0 |
| 143 | fi |
| 144 | |
| 145 | log "Adding 'Intelligent Pinyin' to GNOME Input Sources (idempotent)..." |
| 146 | |
| 147 | CURRENT_SOURCES="$(query_as_user gsettings get org.gnome.desktop.input-sources sources 2>/dev/null || echo '[]')" |
| 148 | # Strip the optional GVariant type annotation used for an empty tuple array. |
| 149 | CLEAN_SOURCES="${CURRENT_SOURCES#@a(ss) }" |
| 150 | CLEAN_SOURCES="${CLEAN_SOURCES#@as }" |
| 151 | |
| 152 | if [[ "$CLEAN_SOURCES" == *"'ibus', 'libpinyin'"* ]]; then |
| 153 | log "Intelligent Pinyin already present in input sources — nothing to do." |
| 154 | else |
| 155 | if [[ "$CLEAN_SOURCES" == *"'ibus', 'rime'"* ]]; then |
| 156 | # Migrate users of the briefly shipped Rime-based version of this script. |
| 157 | NEW_SOURCES="${CLEAN_SOURCES//\'rime\'/\'libpinyin\'}" |
| 158 | elif [[ -z "$CLEAN_SOURCES" || "$CLEAN_SOURCES" == "[]" ]]; then |
| 159 | NEW_SOURCES="[('xkb', 'us'), ('ibus', 'libpinyin')]" |
| 160 | else |
| 161 | # Insert the libpinyin tuple before the closing bracket of the existing list. |
| 162 | NEW_SOURCES="${CLEAN_SOURCES%]*}, ('ibus', 'libpinyin')]" |
| 163 | fi |
| 164 | run_as_user gsettings set org.gnome.desktop.input-sources sources "$NEW_SOURCES" |
| 165 | log "Input sources updated to include: ('ibus', 'libpinyin')" |
| 166 | fi |
| 167 | |
| 168 | log "---" |
| 169 | log "Done! Intelligent Pinyin is installed and configured." |
| 170 | log "Switch input methods with: Super + Space" |
| 171 | log "If 'Chinese (Intelligent Pinyin)' doesn't appear in the top bar right away, log out and back in." |
| 172 |
| 1 | #!/usr/bin/env bash |
| 2 | # install-ipatool.sh — Install ipatool from the latest GitHub release. |
| 3 | # Hardened: arch detection (amd64/arm64), GitHub API token support, SHA-256 verification |
| 4 | # from the release checksum file, atomic install to /usr/local/bin, --dry-run. |
| 5 | |
| 6 | set -euo pipefail |
| 7 | IFS=$'\n\t' |
| 8 | |
| 9 | readonly SCRIPT_NAME="${0##*/}" |
| 10 | DRY_RUN=0 |
| 11 | REPO="majd/ipatool" |
| 12 | INSTALL_PATH="/usr/local/bin/ipatool" |
| 13 | |
| 14 | usage() { |
| 15 | cat <<EOF |
| 16 | Usage: sudo $SCRIPT_NAME [--dry-run] [--help] |
| 17 | |
| 18 | Resolves the latest release of majd/ipatool, downloads the tarball for your |
| 19 | architecture, verifies its SHA-256 against the published checksums.txt, and |
| 20 | installs the binary to /usr/local/bin/ipatool atomically. |
| 21 | |
| 22 | If \$GITHUB_TOKEN is set in the environment, it is used to authenticate the |
| 23 | GitHub API request (avoids rate limits). |
| 24 | |
| 25 | Options: |
| 26 | --dry-run Print actions without executing. |
| 27 | --help, -h Show this help. |
| 28 | EOF |
| 29 | } |
| 30 | |
| 31 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 32 | warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; } |
| 33 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 34 | run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; } |
| 35 | trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR |
| 36 | |
| 37 | while (( $# )); do |
| 38 | case "$1" in |
| 39 | --dry-run) DRY_RUN=1 ;; |
| 40 | -h|--help) usage; exit 0 ;; |
| 41 | *) die "Unknown argument: $1 (try --help)" ;; |
| 42 | esac |
| 43 | shift |
| 44 | done |
| 45 | |
| 46 | (( EUID == 0 )) || die "Must run as root. Try: sudo $SCRIPT_NAME" |
| 47 | |
| 48 | [[ -r /etc/os-release ]] || die "/etc/os-release not found." |
| 49 | # shellcheck disable=SC1091 |
| 50 | . /etc/os-release |
| 51 | log "Detected: ${PRETTY_NAME:-unknown}" |
| 52 | |
| 53 | ARCH_RAW="$(uname -m)" |
| 54 | case "$ARCH_RAW" in |
| 55 | x86_64) GO_ARCH=amd64 ;; |
| 56 | aarch64) GO_ARCH=arm64 ;; |
| 57 | armv7l|armv6l) GO_ARCH=arm ;; |
| 58 | *) die "Unsupported architecture: $ARCH_RAW" ;; |
| 59 | esac |
| 60 | ASSET_SUFFIX="linux-${GO_ARCH}.tar.gz" |
| 61 | |
| 62 | export DEBIAN_FRONTEND=noninteractive |
| 63 | log "Installing prerequisites..." |
| 64 | run "apt-get update -qq" |
| 65 | run "apt-get install -y curl jq tar ca-certificates libsecret-1-0" |
| 66 | |
| 67 | GH_HDRS=(-H "Accept: application/vnd.github+json") |
| 68 | [[ -n "${GITHUB_TOKEN:-}" ]] && GH_HDRS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}") |
| 69 | |
| 70 | log "Querying GitHub API for latest release of $REPO..." |
| 71 | RELEASE_JSON="$(curl -fsSL "${GH_HDRS[@]}" "https://api.github.com/repos/${REPO}/releases/latest")" |
| 72 | TAG="$(jq -r '.tag_name // empty' <<<"$RELEASE_JSON")" |
| 73 | [[ -n "$TAG" ]] || die "Could not parse latest release tag (rate limited? set GITHUB_TOKEN)." |
| 74 | log "Latest release: $TAG" |
| 75 | |
| 76 | DOWNLOAD_URL="$(jq -r --arg s "$ASSET_SUFFIX" '.assets[] | select(.name | endswith($s)) | .browser_download_url' <<<"$RELEASE_JSON" | head -n1)" |
| 77 | CHECKSUM_URL="$(jq -r '.assets[] | select(.name | test("checksums?\\.txt$")) | .browser_download_url' <<<"$RELEASE_JSON" | head -n1)" |
| 78 | [[ "$DOWNLOAD_URL" =~ ^https:// ]] || die "No release asset matching '*${ASSET_SUFFIX}'." |
| 79 | |
| 80 | STAGE="$(mktemp -d -t ipatool.XXXXXX)" |
| 81 | trap 'rm -rf "$STAGE"' EXIT |
| 82 | TARBALL="$STAGE/ipatool.tar.gz" |
| 83 | |
| 84 | log "Downloading $(basename "$DOWNLOAD_URL")..." |
| 85 | run "curl -fsSL -o '$TARBALL' '$DOWNLOAD_URL'" |
| 86 | |
| 87 | if [[ -n "$CHECKSUM_URL" ]]; then |
| 88 | log "Verifying SHA-256..." |
| 89 | EXPECTED="$(curl -fsSL "$CHECKSUM_URL" | awk -v f="$(basename "$DOWNLOAD_URL")" '$2 ~ f || $2 == "*"f {print $1; exit}')" |
| 90 | ACTUAL="$(sha256sum "$TARBALL" | awk '{print $1}')" |
| 91 | if [[ -n "$EXPECTED" && "$EXPECTED" != "$ACTUAL" ]]; then |
| 92 | die "SHA-256 mismatch: expected=$EXPECTED actual=$ACTUAL" |
| 93 | fi |
| 94 | [[ -n "$EXPECTED" ]] && log "SHA-256 ok." || warn "Asset not listed in checksums.txt; skipping." |
| 95 | else |
| 96 | warn "No checksums.txt in release; skipping SHA-256 verification." |
| 97 | fi |
| 98 | |
| 99 | log "Extracting..." |
| 100 | run "tar -xzf '$TARBALL' -C '$STAGE'" |
| 101 | BINARY_PATH="$(find "$STAGE" -type f -name ipatool -executable -not -name '*.tar.gz' | head -n1 || true)" |
| 102 | if [[ -z "$BINARY_PATH" ]]; then |
| 103 | # Some releases ship the binary without +x; relax the find |
| 104 | BINARY_PATH="$(find "$STAGE" -type f -name ipatool -not -name '*.tar.gz' | head -n1 || true)" |
| 105 | fi |
| 106 | [[ -n "$BINARY_PATH" || $DRY_RUN -eq 1 ]] || die "ipatool binary not found inside archive." |
| 107 | |
| 108 | log "Installing to $INSTALL_PATH..." |
| 109 | run "install -m 0755 '$BINARY_PATH' '$INSTALL_PATH'" |
| 110 | |
| 111 | if (( ! DRY_RUN )) && command -v ipatool >/dev/null 2>&1; then |
| 112 | log "Installed: $(ipatool --version 2>/dev/null || basename "$INSTALL_PATH") ($TAG)" |
| 113 | fi |
| 114 | log "Done." |
| 115 |
| 1 | #!/usr/bin/env bash |
| 2 | # install-jetbrains-toolbox.sh — Install JetBrains Toolbox into the invoking user's home. |
| 3 | # Hardened: dependency check w/ auto-install, SHA-256 verification against JetBrains' |
| 4 | # release metadata, idempotent (replaces atomically), .desktop entry, --dry-run. |
| 5 | |
| 6 | set -euo pipefail |
| 7 | IFS=$'\n\t' |
| 8 | |
| 9 | readonly SCRIPT_NAME="${0##*/}" |
| 10 | DRY_RUN=0 |
| 11 | ASSUME_YES=0 |
| 12 | |
| 13 | usage() { |
| 14 | cat <<EOF |
| 15 | Usage: $SCRIPT_NAME [--dry-run] [--yes] [--help] |
| 16 | |
| 17 | Installs the latest JetBrains Toolbox to ~/.local/share/JetBrains/Toolbox and |
| 18 | creates a .desktop launcher in ~/.local/share/applications. The .tar.gz is |
| 19 | verified against the SHA-256 published in JetBrains' release feed. |
| 20 | |
| 21 | Do NOT run with sudo: Toolbox is a per-user install. If a missing system |
| 22 | package (curl/jq/tar/libfuse2) needs installing, the script will call sudo |
| 23 | just for that step. |
| 24 | |
| 25 | Options: |
| 26 | --yes, -y Auto-confirm prompts for installing missing system packages. |
| 27 | --dry-run Print actions without executing. |
| 28 | --help, -h Show this help. |
| 29 | EOF |
| 30 | } |
| 31 | |
| 32 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 33 | warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; } |
| 34 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 35 | run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; } |
| 36 | trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR |
| 37 | |
| 38 | while (( $# )); do |
| 39 | case "$1" in |
| 40 | --dry-run) DRY_RUN=1 ;; |
| 41 | -y|--yes) ASSUME_YES=1 ;; |
| 42 | -h|--help) usage; exit 0 ;; |
| 43 | *) die "Unknown argument: $1 (try --help)" ;; |
| 44 | esac |
| 45 | shift |
| 46 | done |
| 47 | |
| 48 | (( EUID != 0 )) || die "Do NOT run as root. Toolbox is a per-user install." |
| 49 | |
| 50 | [[ -r /etc/os-release ]] || die "/etc/os-release not found." |
| 51 | # shellcheck disable=SC1091 |
| 52 | . /etc/os-release |
| 53 | case "${ID:-}:${ID_LIKE:-}" in |
| 54 | *ubuntu*|*debian*) PKG_MGR=apt ;; |
| 55 | *fedora*|*rhel*) PKG_MGR=dnf ;; |
| 56 | *arch*) PKG_MGR=pacman ;; |
| 57 | *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;; |
| 58 | esac |
| 59 | log "Detected: ${PRETTY_NAME:-unknown} (pkg: $PKG_MGR)" |
| 60 | |
| 61 | confirm() { |
| 62 | (( ASSUME_YES )) && return 0 |
| 63 | read -rp "$1 [y/N] " ans |
| 64 | [[ "$ans" =~ ^[Yy] ]] |
| 65 | } |
| 66 | |
| 67 | ensure_pkg() { |
| 68 | local pkg="$1" probe="$2" |
| 69 | if eval "$probe" >/dev/null 2>&1; then return 0; fi |
| 70 | if ! confirm "Package '$pkg' is missing. Install via sudo $PKG_MGR?"; then |
| 71 | die "'$pkg' is required." |
| 72 | fi |
| 73 | case "$PKG_MGR" in |
| 74 | apt) run "sudo apt-get update -qq && sudo apt-get install -y '$pkg'" ;; |
| 75 | dnf) run "sudo dnf install -y '$pkg'" ;; |
| 76 | pacman) run "sudo pacman -Sy --noconfirm '$pkg'" ;; |
| 77 | esac |
| 78 | } |
| 79 | |
| 80 | # Map deps: command-or-package-probe |
| 81 | ensure_pkg curl "command -v curl" |
| 82 | ensure_pkg jq "command -v jq" |
| 83 | ensure_pkg tar "command -v tar" |
| 84 | case "$PKG_MGR" in |
| 85 | apt) ensure_pkg libfuse2 "dpkg -s libfuse2" ;; |
| 86 | dnf) ensure_pkg fuse-libs "rpm -q fuse-libs" ;; |
| 87 | pacman) ensure_pkg fuse2 "pacman -Q fuse2" ;; |
| 88 | esac |
| 89 | |
| 90 | ARCH_RAW="$(uname -m)" |
| 91 | case "$ARCH_RAW" in |
| 92 | x86_64) TOOLBOX_ARCH_KEY=linux ;; |
| 93 | aarch64) TOOLBOX_ARCH_KEY=linuxARM64 ;; |
| 94 | *) die "Unsupported architecture: $ARCH_RAW" ;; |
| 95 | esac |
| 96 | |
| 97 | log "Querying JetBrains release feed..." |
| 98 | RELEASE_JSON="$(curl -fsSL 'https://data.services.jetbrains.com/products/releases?code=TBA&latest=true&type=release' \ |
| 99 | -H 'Origin: https://www.jetbrains.com' \ |
| 100 | -H 'Referer: https://www.jetbrains.com/toolbox/download/')" |
| 101 | |
| 102 | TOOLBOX_URL="$(jq -r --arg k "$TOOLBOX_ARCH_KEY" '.TBA[0].downloads[$k].link // empty' <<<"$RELEASE_JSON")" |
| 103 | TOOLBOX_SHA="$(jq -r --arg k "$TOOLBOX_ARCH_KEY" '.TBA[0].downloads[$k].checksumLink // empty' <<<"$RELEASE_JSON")" |
| 104 | TOOLBOX_VER="$(jq -r '.TBA[0].version // "?"' <<<"$RELEASE_JSON")" |
| 105 | [[ -n "$TOOLBOX_URL" ]] || die "Could not resolve Toolbox download URL from release feed." |
| 106 | log "Latest Toolbox: $TOOLBOX_VER ($TOOLBOX_ARCH_KEY)" |
| 107 | |
| 108 | INSTALL_DIR="$HOME/.local/share/JetBrains/Toolbox" |
| 109 | STAGE_DIR="$(mktemp -d -t toolbox.XXXXXX)" |
| 110 | trap 'rm -rf "$STAGE_DIR"' EXIT |
| 111 | |
| 112 | TARBALL="$STAGE_DIR/toolbox.tar.gz" |
| 113 | log "Downloading..." |
| 114 | run "curl -fsSL -o '$TARBALL' '$TOOLBOX_URL'" |
| 115 | |
| 116 | if [[ -n "$TOOLBOX_SHA" ]]; then |
| 117 | log "Verifying SHA-256..." |
| 118 | EXPECTED_SHA="$(curl -fsSL "$TOOLBOX_SHA" | awk '{print $1}')" |
| 119 | ACTUAL_SHA="$(sha256sum "$TARBALL" | awk '{print $1}')" |
| 120 | if [[ -n "$EXPECTED_SHA" && "$EXPECTED_SHA" != "$ACTUAL_SHA" ]]; then |
| 121 | die "SHA-256 mismatch! expected=$EXPECTED_SHA actual=$ACTUAL_SHA" |
| 122 | fi |
| 123 | log "SHA-256 ok." |
| 124 | else |
| 125 | warn "No checksum URL in release feed; skipping verification." |
| 126 | fi |
| 127 | |
| 128 | log "Extracting to $INSTALL_DIR..." |
| 129 | run "mkdir -p '$INSTALL_DIR'" |
| 130 | run "tar -xzf '$TARBALL' --strip-components=1 -C '$INSTALL_DIR'" |
| 131 | |
| 132 | BIN_PATH="$INSTALL_DIR/bin/jetbrains-toolbox" |
| 133 | DESKTOP_SRC="$INSTALL_DIR/bin/jetbrains-toolbox.desktop" |
| 134 | ICON_PATH="$INSTALL_DIR/bin/toolbox-tray-color.png" |
| 135 | |
| 136 | [[ -x "$BIN_PATH" || $DRY_RUN -eq 1 ]] || die "Toolbox binary missing after extract." |
| 137 | |
| 138 | # Optional ~/bin symlink |
| 139 | if [[ -d "$HOME/bin" ]]; then |
| 140 | run "ln -sfn '$BIN_PATH' '$HOME/bin/jetbrains-toolbox'" |
| 141 | fi |
| 142 | |
| 143 | # .desktop launcher |
| 144 | APPS_DIR="$HOME/.local/share/applications" |
| 145 | run "mkdir -p '$APPS_DIR'" |
| 146 | DESKTOP_DST="$APPS_DIR/jetbrains-toolbox.desktop" |
| 147 | if [[ -f "$DESKTOP_SRC" ]] || (( DRY_RUN )); then |
| 148 | run "cp '$DESKTOP_SRC' '$DESKTOP_DST'" |
| 149 | run "sed -i 's|^Exec=.*|Exec=$BIN_PATH %u|' '$DESKTOP_DST'" |
| 150 | if [[ -f "$ICON_PATH" ]]; then |
| 151 | run "sed -i 's|^Icon=.*|Icon=$ICON_PATH|' '$DESKTOP_DST'" |
| 152 | fi |
| 153 | run "chmod 0755 '$DESKTOP_DST'" |
| 154 | log "Desktop entry installed: $DESKTOP_DST" |
| 155 | else |
| 156 | warn "No .desktop file in archive; skipping launcher." |
| 157 | fi |
| 158 | |
| 159 | log "Done. JetBrains Toolbox $TOOLBOX_VER installed to $INSTALL_DIR" |
| 160 |
| 1 | #!/usr/bin/env bash |
| 2 | # install-libreoffice.sh — Install the latest stable LibreOffice from documentfoundation.org. |
| 3 | # Hardened: arch detection, version JSON when available with HTML fallback, MD5 verification, |
| 4 | # uninstalls bundled distro libreoffice* first to avoid conflicts, --dry-run. |
| 5 | |
| 6 | set -euo pipefail |
| 7 | IFS=$'\n\t' |
| 8 | |
| 9 | readonly SCRIPT_NAME="${0##*/}" |
| 10 | DRY_RUN=0 |
| 11 | SKIP_REMOVE_DISTRO=0 |
| 12 | |
| 13 | usage() { |
| 14 | cat <<EOF |
| 15 | Usage: sudo $SCRIPT_NAME [--dry-run] [--keep-distro-libreoffice] [--help] |
| 16 | |
| 17 | Detects the latest stable LibreOffice version on download.documentfoundation.org, |
| 18 | downloads the matching DEB tarball for your architecture, verifies the MD5 sum |
| 19 | published alongside it, and installs the .deb packages. |
| 20 | |
| 21 | Options: |
| 22 | --keep-distro-libreoffice Don't purge any pre-installed distro libreoffice* |
| 23 | (default: purge to avoid library conflicts). |
| 24 | --dry-run Print actions without executing. |
| 25 | --help, -h Show this help. |
| 26 | EOF |
| 27 | } |
| 28 | |
| 29 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 30 | warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; } |
| 31 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 32 | run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; } |
| 33 | trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR |
| 34 | |
| 35 | while (( $# )); do |
| 36 | case "$1" in |
| 37 | --dry-run) DRY_RUN=1 ;; |
| 38 | --keep-distro-libreoffice) SKIP_REMOVE_DISTRO=1 ;; |
| 39 | -h|--help) usage; exit 0 ;; |
| 40 | *) die "Unknown argument: $1 (try --help)" ;; |
| 41 | esac |
| 42 | shift |
| 43 | done |
| 44 | |
| 45 | (( EUID == 0 )) || die "Must run as root. Try: sudo $SCRIPT_NAME" |
| 46 | |
| 47 | [[ -r /etc/os-release ]] || die "/etc/os-release not found." |
| 48 | # shellcheck disable=SC1091 |
| 49 | . /etc/os-release |
| 50 | case "${ID:-}:${ID_LIKE:-}" in |
| 51 | *ubuntu*|*debian*) : ;; |
| 52 | *) die "Unsupported distro: ${PRETTY_NAME:-unknown} (this script installs .deb packages)." ;; |
| 53 | esac |
| 54 | |
| 55 | ARCH_RAW="$(uname -m)" |
| 56 | case "$ARCH_RAW" in |
| 57 | x86_64) LO_ARCH=x86_64; LO_SUFFIX=Linux_x86-64 ;; |
| 58 | aarch64) LO_ARCH=aarch64; LO_SUFFIX=Linux_aarch64 ;; |
| 59 | *) die "Unsupported architecture: $ARCH_RAW (LibreOffice ships x86_64 and aarch64)." ;; |
| 60 | esac |
| 61 | log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH_RAW" |
| 62 | |
| 63 | export DEBIAN_FRONTEND=noninteractive |
| 64 | |
| 65 | log "Installing prerequisites..." |
| 66 | run "apt-get update -qq" |
| 67 | run "apt-get install -y curl wget tar coreutils ca-certificates" |
| 68 | |
| 69 | log "Resolving latest stable LibreOffice version..." |
| 70 | INDEX="$(curl -fsSL https://download.documentfoundation.org/libreoffice/stable/)" |
| 71 | LATEST_VERSION="$(printf '%s\n' "$INDEX" \ |
| 72 | | grep -oP '(?<=href=")[0-9]+\.[0-9]+\.[0-9]+(?=/")' \ |
| 73 | | sort -V | tail -1 || true)" |
| 74 | [[ -n "$LATEST_VERSION" ]] || die "Could not detect latest version from index page." |
| 75 | log "Latest stable: $LATEST_VERSION" |
| 76 | |
| 77 | BASE="https://download.documentfoundation.org/libreoffice/stable/${LATEST_VERSION}/deb/${LO_ARCH}" |
| 78 | ARCHIVE_NAME="LibreOffice_${LATEST_VERSION}_${LO_SUFFIX}_deb.tar.gz" |
| 79 | URL="${BASE}/${ARCHIVE_NAME}" |
| 80 | SUM_URL="${URL}.md5" |
| 81 | |
| 82 | STAGE="$(mktemp -d -t lo.XXXXXX)" |
| 83 | trap 'rm -rf "$STAGE"' EXIT |
| 84 | ARCHIVE="$STAGE/$ARCHIVE_NAME" |
| 85 | |
| 86 | log "Downloading $ARCHIVE_NAME..." |
| 87 | run "wget -q --show-progress -O '$ARCHIVE' '$URL'" |
| 88 | |
| 89 | log "Verifying MD5..." |
| 90 | if EXPECTED_MD5="$(curl -fsSL "$SUM_URL" 2>/dev/null | awk '{print $1}')" && [[ -n "$EXPECTED_MD5" ]]; then |
| 91 | ACTUAL_MD5="$(md5sum "$ARCHIVE" | awk '{print $1}')" |
| 92 | [[ "$EXPECTED_MD5" == "$ACTUAL_MD5" ]] || die "MD5 mismatch: expected=$EXPECTED_MD5 actual=$ACTUAL_MD5" |
| 93 | log "MD5 ok." |
| 94 | else |
| 95 | warn "No MD5 published for $SUM_URL; skipping checksum." |
| 96 | fi |
| 97 | |
| 98 | log "Extracting..." |
| 99 | run "tar -xzf '$ARCHIVE' -C '$STAGE'" |
| 100 | DEBS_DIR="$(find "$STAGE" -maxdepth 3 -type d -name DEBS | head -n1 || true)" |
| 101 | [[ -n "$DEBS_DIR" || $DRY_RUN -eq 1 ]] || die "DEBS/ directory not found in archive." |
| 102 | |
| 103 | if (( ! SKIP_REMOVE_DISTRO )); then |
| 104 | if dpkg -l 'libreoffice*' 2>/dev/null | awk '/^ii/ {print $2}' | grep -q .; then |
| 105 | log "Removing distro-supplied libreoffice* packages to avoid conflicts..." |
| 106 | run "apt-get remove --purge -y 'libreoffice*'" |
| 107 | fi |
| 108 | fi |
| 109 | |
| 110 | log "Installing .deb packages..." |
| 111 | run "dpkg -i -R '$DEBS_DIR'" |
| 112 | log "Resolving any missing dependencies..." |
| 113 | run "apt-get install -f -y" |
| 114 | |
| 115 | log "Done. LibreOffice $LATEST_VERSION installed." |
| 116 |
| 1 | #!/usr/bin/env bash |
| 2 | # install-localsend.sh — Install LocalSend from the latest official GitHub release. |
| 3 | # Hardened: arch-aware, GitHub API token support, SHA-256 verification from the |
| 4 | # release asset digest, idempotent, --dry-run. |
| 5 | |
| 6 | set -euo pipefail |
| 7 | IFS=$'\n\t' |
| 8 | |
| 9 | readonly SCRIPT_NAME="${0##*/}" |
| 10 | DRY_RUN=0 |
| 11 | REPO="localsend/localsend" |
| 12 | |
| 13 | usage() { |
| 14 | cat <<EOF |
| 15 | Usage: sudo $SCRIPT_NAME [--dry-run] [--help] |
| 16 | |
| 17 | Resolves the latest release of localsend/localsend, downloads the official |
| 18 | Linux .deb package for your architecture, verifies its SHA-256 against the |
| 19 | GitHub release asset digest when available, and installs it with apt-get. |
| 20 | |
| 21 | If \$GITHUB_TOKEN is set in the environment, it is used to authenticate the |
| 22 | GitHub API request (avoids rate limits). |
| 23 | |
| 24 | Options: |
| 25 | --dry-run Print actions without executing. |
| 26 | --help, -h Show this help. |
| 27 | EOF |
| 28 | } |
| 29 | |
| 30 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 31 | warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; } |
| 32 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 33 | run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; } |
| 34 | trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR |
| 35 | |
| 36 | while (( $# )); do |
| 37 | case "$1" in |
| 38 | --dry-run) DRY_RUN=1 ;; |
| 39 | -h|--help) usage; exit 0 ;; |
| 40 | *) die "Unknown argument: $1 (try --help)" ;; |
| 41 | esac |
| 42 | shift |
| 43 | done |
| 44 | |
| 45 | (( EUID == 0 )) || die "Must run as root. Try: sudo $SCRIPT_NAME" |
| 46 | |
| 47 | [[ -r /etc/os-release ]] || die "/etc/os-release not found." |
| 48 | # shellcheck disable=SC1091 |
| 49 | . /etc/os-release |
| 50 | case "${ID:-}:${ID_LIKE:-}" in |
| 51 | *ubuntu*|*debian*) : ;; |
| 52 | *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;; |
| 53 | esac |
| 54 | |
| 55 | ARCH="$(dpkg --print-architecture)" |
| 56 | case "$ARCH" in |
| 57 | amd64) ASSET_ARCH="x86-64" ;; |
| 58 | arm64) ASSET_ARCH="arm-64" ;; |
| 59 | *) die "LocalSend publishes Linux .deb assets for amd64 and arm64 only (detected: $ARCH)." ;; |
| 60 | esac |
| 61 | log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH" |
| 62 | |
| 63 | export DEBIAN_FRONTEND=noninteractive |
| 64 | log "Installing prerequisites..." |
| 65 | run "apt-get update -qq" |
| 66 | run "apt-get install -y curl jq ca-certificates" |
| 67 | |
| 68 | GH_HDRS=(-H "Accept: application/vnd.github+json") |
| 69 | [[ -n "${GITHUB_TOKEN:-}" ]] && GH_HDRS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}") |
| 70 | |
| 71 | log "Querying GitHub API for latest release of $REPO..." |
| 72 | RELEASE_JSON="$(curl -fsSL "${GH_HDRS[@]}" "https://api.github.com/repos/${REPO}/releases/latest")" |
| 73 | TAG="$(jq -r '.tag_name // empty' <<<"$RELEASE_JSON")" |
| 74 | [[ -n "$TAG" ]] || die "Could not parse latest release tag (rate limited? set GITHUB_TOKEN)." |
| 75 | log "Latest release: $TAG" |
| 76 | |
| 77 | ASSET_REGEX="^LocalSend-[^-]+-linux-${ASSET_ARCH}\\.deb$" |
| 78 | DOWNLOAD_URL="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | .browser_download_url' <<<"$RELEASE_JSON" | head -n1)" |
| 79 | ASSET_NAME="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | .name' <<<"$RELEASE_JSON" | head -n1)" |
| 80 | EXPECTED_SHA="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | (.digest // "") | sub("^sha256:"; "")' <<<"$RELEASE_JSON" | head -n1)" |
| 81 | [[ "$DOWNLOAD_URL" =~ ^https:// ]] || die "No $ARCH .deb asset found in latest LocalSend release." |
| 82 | |
| 83 | STAGE="$(mktemp -d -t localsend.XXXXXX)" |
| 84 | trap 'rm -rf "$STAGE"' EXIT |
| 85 | DEB="$STAGE/$ASSET_NAME" |
| 86 | |
| 87 | log "Downloading $ASSET_NAME..." |
| 88 | run "curl -fL -o '$DEB' '$DOWNLOAD_URL'" |
| 89 | |
| 90 | if (( DRY_RUN )); then |
| 91 | [[ -n "$EXPECTED_SHA" && "$EXPECTED_SHA" != "null" ]] \ |
| 92 | && printf ' DRY-RUN: verify SHA-256 %s\n' "$EXPECTED_SHA" \ |
| 93 | || printf ' DRY-RUN: skip SHA-256 verification; no GitHub asset digest found\n' |
| 94 | printf ' DRY-RUN: apt-get install -y %s\n' "$DEB" |
| 95 | log "Done." |
| 96 | exit 0 |
| 97 | fi |
| 98 | |
| 99 | if [[ -n "$EXPECTED_SHA" && "$EXPECTED_SHA" != "null" ]]; then |
| 100 | log "Verifying SHA-256..." |
| 101 | ACTUAL_SHA="$(sha256sum "$DEB" | awk '{print $1}')" |
| 102 | [[ "$EXPECTED_SHA" == "$ACTUAL_SHA" ]] || die "SHA-256 mismatch: expected=$EXPECTED_SHA actual=$ACTUAL_SHA" |
| 103 | log "SHA-256 ok." |
| 104 | else |
| 105 | warn "No SHA-256 asset digest in GitHub release metadata; skipping verification." |
| 106 | fi |
| 107 | |
| 108 | log "Installing LocalSend..." |
| 109 | run "apt-get install -y '$DEB'" |
| 110 | |
| 111 | if command -v localsend_app >/dev/null 2>&1; then |
| 112 | log "Installed: $(localsend_app --version 2>/dev/null || echo "$TAG")" |
| 113 | else |
| 114 | log "Installed: $TAG" |
| 115 | fi |
| 116 | log "Done." |
| 117 |
| 1 | #!/usr/bin/env bash |
| 2 | # install-marktext.sh — Install MarkText from the latest official GitHub release. |
| 3 | # Hardened: architecture check, GitHub API token support, SHA-256 verification |
| 4 | # from the release asset digest, idempotent, --dry-run. |
| 5 | |
| 6 | set -euo pipefail |
| 7 | IFS=$'\n\t' |
| 8 | |
| 9 | readonly SCRIPT_NAME="${0##*/}" |
| 10 | DRY_RUN=0 |
| 11 | REPO="marktext/marktext" |
| 12 | |
| 13 | usage() { |
| 14 | cat <<EOF |
| 15 | Usage: sudo $SCRIPT_NAME [--dry-run] [--help] |
| 16 | |
| 17 | Resolves the latest stable release of marktext/marktext, downloads the official |
| 18 | Linux amd64 .deb package, verifies its SHA-256 against the GitHub release asset |
| 19 | digest, and installs it with apt-get. |
| 20 | |
| 21 | If \$GITHUB_TOKEN is set in the environment, it is used to authenticate the |
| 22 | GitHub API request (avoids rate limits). |
| 23 | |
| 24 | Options: |
| 25 | --dry-run Print actions without executing. |
| 26 | --help, -h Show this help. |
| 27 | EOF |
| 28 | } |
| 29 | |
| 30 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 31 | warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; } |
| 32 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 33 | run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; } |
| 34 | trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR |
| 35 | |
| 36 | while (( $# )); do |
| 37 | case "$1" in |
| 38 | --dry-run) DRY_RUN=1 ;; |
| 39 | -h|--help) usage; exit 0 ;; |
| 40 | *) die "Unknown argument: $1 (try --help)" ;; |
| 41 | esac |
| 42 | shift |
| 43 | done |
| 44 | |
| 45 | (( EUID == 0 )) || die "Must run as root. Try: sudo $SCRIPT_NAME" |
| 46 | |
| 47 | [[ -r /etc/os-release ]] || die "/etc/os-release not found." |
| 48 | # shellcheck disable=SC1091 |
| 49 | . /etc/os-release |
| 50 | case "${ID:-}:${ID_LIKE:-}" in |
| 51 | *ubuntu*|*debian*) : ;; |
| 52 | *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;; |
| 53 | esac |
| 54 | |
| 55 | ARCH="$(dpkg --print-architecture)" |
| 56 | [[ "$ARCH" == "amd64" ]] || die "MarkText publishes its Linux .deb for amd64 only (detected: $ARCH)." |
| 57 | log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH" |
| 58 | |
| 59 | export DEBIAN_FRONTEND=noninteractive |
| 60 | log "Installing prerequisites..." |
| 61 | run "apt-get update -qq" |
| 62 | run "apt-get install -y curl jq ca-certificates" |
| 63 | |
| 64 | GH_HDRS=(-H "Accept: application/vnd.github+json") |
| 65 | [[ -n "${GITHUB_TOKEN:-}" ]] && GH_HDRS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}") |
| 66 | |
| 67 | log "Querying GitHub API for latest release of $REPO..." |
| 68 | RELEASE_JSON="$(curl -fsSL "${GH_HDRS[@]}" "https://api.github.com/repos/${REPO}/releases/latest")" |
| 69 | TAG="$(jq -r '.tag_name // empty' <<<"$RELEASE_JSON")" |
| 70 | [[ -n "$TAG" ]] || die "Could not parse latest release tag (rate limited? set GITHUB_TOKEN)." |
| 71 | log "Latest release: $TAG" |
| 72 | |
| 73 | ASSET_REGEX='^marktext-linux-[^/]+\.deb$' |
| 74 | DOWNLOAD_URL="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | .browser_download_url' <<<"$RELEASE_JSON" | head -n1)" |
| 75 | ASSET_NAME="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | .name' <<<"$RELEASE_JSON" | head -n1)" |
| 76 | EXPECTED_SHA="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | (.digest // "") | sub("^sha256:"; "")' <<<"$RELEASE_JSON" | head -n1)" |
| 77 | [[ "$DOWNLOAD_URL" =~ ^https:// ]] || die "No amd64 .deb asset found in latest MarkText release." |
| 78 | |
| 79 | STAGE="$(mktemp -d -t marktext.XXXXXX)" |
| 80 | trap 'rm -rf "$STAGE"' EXIT |
| 81 | DEB="$STAGE/$ASSET_NAME" |
| 82 | |
| 83 | log "Downloading $ASSET_NAME..." |
| 84 | run "curl -fsSL -o '$DEB' '$DOWNLOAD_URL'" |
| 85 | |
| 86 | if (( DRY_RUN )); then |
| 87 | [[ -n "$EXPECTED_SHA" && "$EXPECTED_SHA" != "null" ]] \ |
| 88 | && printf ' DRY-RUN: verify SHA-256 %s\n' "$EXPECTED_SHA" \ |
| 89 | || printf ' DRY-RUN: skip SHA-256 verification; no GitHub asset digest found\n' |
| 90 | printf ' DRY-RUN: apt-get install -y %s\n' "$DEB" |
| 91 | log "Done." |
| 92 | exit 0 |
| 93 | fi |
| 94 | |
| 95 | if [[ -n "$EXPECTED_SHA" && "$EXPECTED_SHA" != "null" ]]; then |
| 96 | log "Verifying SHA-256..." |
| 97 | ACTUAL_SHA="$(sha256sum "$DEB" | awk '{print $1}')" |
| 98 | [[ "$EXPECTED_SHA" == "$ACTUAL_SHA" ]] || die "SHA-256 mismatch: expected=$EXPECTED_SHA actual=$ACTUAL_SHA" |
| 99 | log "SHA-256 ok." |
| 100 | else |
| 101 | warn "No SHA-256 asset digest in GitHub release metadata; skipping verification." |
| 102 | fi |
| 103 | |
| 104 | log "Installing MarkText..." |
| 105 | run "apt-get install -y '$DEB'" |
| 106 | |
| 107 | if command -v marktext >/dev/null 2>&1; then |
| 108 | log "Installed: $(marktext --version 2>/dev/null || echo "$TAG")" |
| 109 | else |
| 110 | log "Installed: $TAG" |
| 111 | fi |
| 112 | log "Done." |
| 113 |
| 1 | #!/usr/bin/env bash |
| 2 | # install-network_drive.sh — Auto-mount Synology (SMB/CIFS) shares under /mnt/Synology. |
| 3 | # Hardened: safe input handling, no eval-on-username, hostname validation, |
| 4 | # fstab managed via begin/end markers (idempotent re-run), creds file 0600, |
| 5 | # GNOME dock pinning is best-effort. |
| 6 | |
| 7 | set -euo pipefail |
| 8 | IFS=$'\n\t' |
| 9 | |
| 10 | readonly SCRIPT_NAME="${0##*/}" |
| 11 | DRY_RUN=0 |
| 12 | SERVER_ADDR="" |
| 13 | SMB_USER="" |
| 14 | SMB_PASS="" |
| 15 | MASTER_DIR="/mnt/Synology" |
| 16 | |
| 17 | usage() { |
| 18 | cat <<EOF |
| 19 | Usage: sudo $SCRIPT_NAME [options] |
| 20 | |
| 21 | Discovers SMB shares on a Synology NAS and adds them to /etc/fstab so they |
| 22 | auto-mount under $MASTER_DIR. Creates a desktop launcher and (best-effort) |
| 23 | pins it to the top of the GNOME Dock. |
| 24 | |
| 25 | Options: |
| 26 | --server HOST_OR_IP Synology address (skips prompt). |
| 27 | --user USERNAME SMB username (skips prompt). |
| 28 | --password-stdin Read SMB password from stdin (skips prompt). |
| 29 | (Otherwise the script prompts on the controlling tty.) |
| 30 | --mount-root DIR Override parent directory (default: $MASTER_DIR). |
| 31 | --dry-run Print actions without executing. |
| 32 | --help, -h Show this help. |
| 33 | EOF |
| 34 | } |
| 35 | |
| 36 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 37 | warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; } |
| 38 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 39 | run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; } |
| 40 | trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR |
| 41 | |
| 42 | PASS_FROM_STDIN=0 |
| 43 | while (( $# )); do |
| 44 | case "$1" in |
| 45 | --server) SERVER_ADDR="${2:?}"; shift ;; |
| 46 | --user) SMB_USER="${2:?}"; shift ;; |
| 47 | --password-stdin) PASS_FROM_STDIN=1 ;; |
| 48 | --mount-root) MASTER_DIR="${2:?}"; shift ;; |
| 49 | --dry-run) DRY_RUN=1 ;; |
| 50 | -h|--help) usage; exit 0 ;; |
| 51 | *) die "Unknown argument: $1 (try --help)" ;; |
| 52 | esac |
| 53 | shift |
| 54 | done |
| 55 | |
| 56 | (( EUID == 0 )) || die "Must run as root. Try: sudo $SCRIPT_NAME" |
| 57 | |
| 58 | [[ -r /etc/os-release ]] || die "/etc/os-release not found." |
| 59 | # shellcheck disable=SC1091 |
| 60 | . /etc/os-release |
| 61 | case "${ID:-}:${ID_LIKE:-}" in |
| 62 | *ubuntu*|*debian*) : ;; |
| 63 | *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;; |
| 64 | esac |
| 65 | |
| 66 | ACTUAL_USER="${SUDO_USER:-${USER:-}}" |
| 67 | [[ -n "$ACTUAL_USER" && "$ACTUAL_USER" != "root" ]] || die "Run via sudo as a regular user." |
| 68 | # Use getent so we don't depend on eval/~expansion or shell glob safety. |
| 69 | USER_ENTRY="$(getent passwd "$ACTUAL_USER")" || die "User '$ACTUAL_USER' not found in passwd." |
| 70 | USER_HOME="$(awk -F: '{print $6}' <<<"$USER_ENTRY")" |
| 71 | USER_ID="$(awk -F: '{print $3}' <<<"$USER_ENTRY")" |
| 72 | USER_GID="$(awk -F: '{print $4}' <<<"$USER_ENTRY")" |
| 73 | |
| 74 | export DEBIAN_FRONTEND=noninteractive |
| 75 | log "Installing cifs-utils, smbclient..." |
| 76 | run "apt-get update -qq" |
| 77 | run "apt-get install -y cifs-utils smbclient" |
| 78 | |
| 79 | # --- Collect creds --- |
| 80 | [[ -n "$SERVER_ADDR" ]] || read -rp "Synology NAS address or IP: " SERVER_ADDR </dev/tty |
| 81 | SERVER_ADDR="${SERVER_ADDR#smb://}" |
| 82 | SERVER_ADDR="${SERVER_ADDR#//}" |
| 83 | SERVER_ADDR="${SERVER_ADDR%/}" |
| 84 | [[ "$SERVER_ADDR" =~ ^[A-Za-z0-9._-]+$ ]] || die "Invalid host/IP: '$SERVER_ADDR'" |
| 85 | |
| 86 | [[ -n "$SMB_USER" ]] || read -rp "Synology username: " SMB_USER </dev/tty |
| 87 | [[ "$SMB_USER" =~ ^[A-Za-z0-9._@-]+$ ]] || die "Invalid SMB username." |
| 88 | |
| 89 | if (( PASS_FROM_STDIN )); then |
| 90 | IFS= read -r SMB_PASS |
| 91 | else |
| 92 | read -rsp "Synology password: " SMB_PASS </dev/tty |
| 93 | echo |
| 94 | fi |
| 95 | [[ -n "$SMB_PASS" ]] || die "Password is empty." |
| 96 | |
| 97 | # --- Credentials file --- |
| 98 | CRED_FILE="$USER_HOME/.smbcredentials_synology" |
| 99 | log "Writing credentials to $CRED_FILE (mode 0600)..." |
| 100 | if (( DRY_RUN )); then |
| 101 | printf ' DRY-RUN: write %s\n' "$CRED_FILE" |
| 102 | else |
| 103 | umask 077 |
| 104 | { |
| 105 | printf 'username=%s\n' "$SMB_USER" |
| 106 | printf 'password=%s\n' "$SMB_PASS" |
| 107 | } >"$CRED_FILE" |
| 108 | chown "$USER_ID:$USER_GID" "$CRED_FILE" |
| 109 | chmod 0600 "$CRED_FILE" |
| 110 | fi |
| 111 | |
| 112 | # --- Query shares --- |
| 113 | log "Discovering shares on //$SERVER_ADDR..." |
| 114 | if (( DRY_RUN )); then |
| 115 | SHARE_LIST=$'home\nphoto\nvideo' |
| 116 | else |
| 117 | SHARE_LIST="$(smbclient -L "//$SERVER_ADDR" -U "$SMB_USER%$SMB_PASS" -g 2>/dev/null \ |
| 118 | | awk -F'|' '$1=="Disk" {print $2}' || true)" |
| 119 | fi |
| 120 | [[ -n "$SHARE_LIST" ]] || die "No shares returned. Check host, credentials, or network." |
| 121 | |
| 122 | # --- Parent mount dir --- |
| 123 | run "mkdir -p '$MASTER_DIR'" |
| 124 | run "chown '$USER_ID:$USER_GID' '$MASTER_DIR'" |
| 125 | |
| 126 | # --- Backup fstab once --- |
| 127 | if [[ ! -f /etc/fstab.bak.synology ]]; then |
| 128 | run "cp /etc/fstab /etc/fstab.bak.synology" |
| 129 | log "Backed up fstab -> /etc/fstab.bak.synology" |
| 130 | fi |
| 131 | |
| 132 | MARK_BEGIN="# >>> synology-master >>> (managed by ${SCRIPT_NAME})" |
| 133 | MARK_END="# <<< synology-master <<<" |
| 134 | # Remove any previous managed block (so re-run replaces, not appends). |
| 135 | if grep -qF "$MARK_BEGIN" /etc/fstab; then |
| 136 | if (( DRY_RUN )); then |
| 137 | printf ' DRY-RUN: strip previous managed block from /etc/fstab\n' |
| 138 | else |
| 139 | sed -i "\|$MARK_BEGIN|,\|$MARK_END|d" /etc/fstab |
| 140 | fi |
| 141 | fi |
| 142 | |
| 143 | log "Writing managed block to /etc/fstab..." |
| 144 | FSTAB_BLOCK="$MARK_BEGIN"$'\n' |
| 145 | while IFS= read -r SHARE; do |
| 146 | [[ -z "$SHARE" || "$SHARE" == "IPC\$" || "$SHARE" == "print\$" ]] && continue |
| 147 | MOUNT_POINT="$MASTER_DIR/$SHARE" |
| 148 | run "mkdir -p '$MOUNT_POINT'" |
| 149 | run "chown '$USER_ID:$USER_GID' '$MOUNT_POINT'" |
| 150 | FSTAB_BLOCK+="//${SERVER_ADDR}/${SHARE} ${MOUNT_POINT} cifs credentials=${CRED_FILE},uid=${USER_ID},gid=${USER_GID},_netdev,nofail,x-systemd.automount,x-systemd.idle-timeout=60 0 0"$'\n' |
| 151 | done <<<"$SHARE_LIST" |
| 152 | FSTAB_BLOCK+="$MARK_END"$'\n' |
| 153 | |
| 154 | if (( DRY_RUN )); then |
| 155 | printf ' DRY-RUN: append fstab block:\n%s\n' "$FSTAB_BLOCK" |
| 156 | else |
| 157 | printf '%s' "$FSTAB_BLOCK" >>/etc/fstab |
| 158 | fi |
| 159 | |
| 160 | log "Reloading systemd and mounting..." |
| 161 | run "systemctl daemon-reload" |
| 162 | run "mount -a -t cifs" |
| 163 | |
| 164 | # --- Desktop entry --- |
| 165 | APPS_DIR="$USER_HOME/.local/share/applications" |
| 166 | DESKTOP_FILENAME="synology-master.desktop" |
| 167 | DESKTOP_FILE="$APPS_DIR/$DESKTOP_FILENAME" |
| 168 | run "install -d -o '$USER_ID' -g '$USER_GID' -m 0755 '$APPS_DIR'" |
| 169 | |
| 170 | if (( DRY_RUN )); then |
| 171 | printf ' DRY-RUN: write %s\n' "$DESKTOP_FILE" |
| 172 | else |
| 173 | cat >"$DESKTOP_FILE" <<EOF |
| 174 | [Desktop Entry] |
| 175 | Name=Synology NAS |
| 176 | Comment=Open Synology Master Directory |
| 177 | Exec=xdg-open ${MASTER_DIR} |
| 178 | Icon=folder-remote |
| 179 | Terminal=false |
| 180 | Type=Application |
| 181 | Categories=Network;FileTools; |
| 182 | EOF |
| 183 | chown "$USER_ID:$USER_GID" "$DESKTOP_FILE" |
| 184 | chmod 0755 "$DESKTOP_FILE" |
| 185 | fi |
| 186 | |
| 187 | # --- Best-effort GNOME dock pin --- |
| 188 | if (( ! DRY_RUN )) && sudo -u "$ACTUAL_USER" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$USER_ID/bus" gsettings list-keys org.gnome.shell >/dev/null 2>&1; then |
| 189 | log "Pinning to top of GNOME dock..." |
| 190 | CURRENT_FAVS="$(sudo -u "$ACTUAL_USER" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$USER_ID/bus" gsettings get org.gnome.shell favorite-apps 2>/dev/null || echo '[]')" |
| 191 | if [[ "$CURRENT_FAVS" != *"$DESKTOP_FILENAME"* ]]; then |
| 192 | CLEAN_FAVS="${CURRENT_FAVS#@as }" |
| 193 | if [[ "$CLEAN_FAVS" == "[]" || -z "$CLEAN_FAVS" ]]; then |
| 194 | NEW_FAVS="['$DESKTOP_FILENAME']" |
| 195 | else |
| 196 | NEW_FAVS="['$DESKTOP_FILENAME', ${CLEAN_FAVS:1}" |
| 197 | fi |
| 198 | sudo -u "$ACTUAL_USER" DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/$USER_ID/bus" \ |
| 199 | gsettings set org.gnome.shell favorite-apps "$NEW_FAVS" || warn "Dock pin failed (non-fatal)." |
| 200 | else |
| 201 | log "Already pinned." |
| 202 | fi |
| 203 | else |
| 204 | log "GNOME not detected (or no active dbus session); skipping dock pin." |
| 205 | fi |
| 206 | |
| 207 | log "Done. Shares mounted under $MASTER_DIR." |
| 208 |
| 1 | #!/usr/bin/env bash |
| 2 | # install-obsidian.sh — Install Obsidian from the latest official GitHub release. |
| 3 | # Hardened: arch-aware, GitHub API token support, SHA-256 verification from the |
| 4 | # release asset digest, idempotent, --dry-run. |
| 5 | |
| 6 | set -euo pipefail |
| 7 | IFS=$'\n\t' |
| 8 | |
| 9 | readonly SCRIPT_NAME="${0##*/}" |
| 10 | DRY_RUN=0 |
| 11 | REPO="obsidianmd/obsidian-releases" |
| 12 | |
| 13 | usage() { |
| 14 | cat <<EOF |
| 15 | Usage: sudo $SCRIPT_NAME [--dry-run] [--help] |
| 16 | |
| 17 | Resolves the latest release of obsidianmd/obsidian-releases, downloads the |
| 18 | official amd64 .deb package, verifies its SHA-256 against the GitHub release |
| 19 | asset digest when available, and installs it with apt-get. |
| 20 | |
| 21 | If \$GITHUB_TOKEN is set in the environment, it is used to authenticate the |
| 22 | GitHub API request (avoids rate limits). |
| 23 | |
| 24 | Options: |
| 25 | --dry-run Print actions without executing. |
| 26 | --help, -h Show this help. |
| 27 | EOF |
| 28 | } |
| 29 | |
| 30 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 31 | warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; } |
| 32 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 33 | run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; } |
| 34 | trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR |
| 35 | |
| 36 | while (( $# )); do |
| 37 | case "$1" in |
| 38 | --dry-run) DRY_RUN=1 ;; |
| 39 | -h|--help) usage; exit 0 ;; |
| 40 | *) die "Unknown argument: $1 (try --help)" ;; |
| 41 | esac |
| 42 | shift |
| 43 | done |
| 44 | |
| 45 | (( EUID == 0 )) || die "Must run as root. Try: sudo $SCRIPT_NAME" |
| 46 | |
| 47 | [[ -r /etc/os-release ]] || die "/etc/os-release not found." |
| 48 | # shellcheck disable=SC1091 |
| 49 | . /etc/os-release |
| 50 | case "${ID:-}:${ID_LIKE:-}" in |
| 51 | *ubuntu*|*debian*) : ;; |
| 52 | *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;; |
| 53 | esac |
| 54 | |
| 55 | ARCH="$(dpkg --print-architecture)" |
| 56 | [[ "$ARCH" == "amd64" ]] || die "Obsidian publishes a Linux .deb for amd64 only (detected: $ARCH)." |
| 57 | log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH" |
| 58 | |
| 59 | export DEBIAN_FRONTEND=noninteractive |
| 60 | log "Installing prerequisites..." |
| 61 | run "apt-get update -qq" |
| 62 | run "apt-get install -y curl jq ca-certificates" |
| 63 | |
| 64 | GH_HDRS=(-H "Accept: application/vnd.github+json") |
| 65 | [[ -n "${GITHUB_TOKEN:-}" ]] && GH_HDRS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}") |
| 66 | |
| 67 | log "Querying GitHub API for latest release of $REPO..." |
| 68 | RELEASE_JSON="$(curl -fsSL "${GH_HDRS[@]}" "https://api.github.com/repos/${REPO}/releases/latest")" |
| 69 | TAG="$(jq -r '.tag_name // empty' <<<"$RELEASE_JSON")" |
| 70 | [[ -n "$TAG" ]] || die "Could not parse latest release tag (rate limited? set GITHUB_TOKEN)." |
| 71 | log "Latest release: $TAG" |
| 72 | |
| 73 | ASSET_REGEX='^obsidian_[^/]+_amd64\.deb$' |
| 74 | DOWNLOAD_URL="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | .browser_download_url' <<<"$RELEASE_JSON" | head -n1)" |
| 75 | ASSET_NAME="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | .name' <<<"$RELEASE_JSON" | head -n1)" |
| 76 | EXPECTED_SHA="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | (.digest // "") | sub("^sha256:"; "")' <<<"$RELEASE_JSON" | head -n1)" |
| 77 | [[ "$DOWNLOAD_URL" =~ ^https:// ]] || die "No amd64 .deb asset found in latest Obsidian release." |
| 78 | |
| 79 | STAGE="$(mktemp -d -t obsidian.XXXXXX)" |
| 80 | trap 'rm -rf "$STAGE"' EXIT |
| 81 | DEB="$STAGE/$ASSET_NAME" |
| 82 | |
| 83 | log "Downloading $ASSET_NAME..." |
| 84 | run "curl -fsSL -o '$DEB' '$DOWNLOAD_URL'" |
| 85 | |
| 86 | if (( DRY_RUN )); then |
| 87 | [[ -n "$EXPECTED_SHA" && "$EXPECTED_SHA" != "null" ]] \ |
| 88 | && printf ' DRY-RUN: verify SHA-256 %s\n' "$EXPECTED_SHA" \ |
| 89 | || printf ' DRY-RUN: skip SHA-256 verification; no GitHub asset digest found\n' |
| 90 | printf ' DRY-RUN: apt-get install -y %s\n' "$DEB" |
| 91 | log "Done." |
| 92 | exit 0 |
| 93 | fi |
| 94 | |
| 95 | if [[ -n "$EXPECTED_SHA" && "$EXPECTED_SHA" != "null" ]]; then |
| 96 | log "Verifying SHA-256..." |
| 97 | ACTUAL_SHA="$(sha256sum "$DEB" | awk '{print $1}')" |
| 98 | [[ "$EXPECTED_SHA" == "$ACTUAL_SHA" ]] || die "SHA-256 mismatch: expected=$EXPECTED_SHA actual=$ACTUAL_SHA" |
| 99 | log "SHA-256 ok." |
| 100 | else |
| 101 | warn "No SHA-256 asset digest in GitHub release metadata; skipping verification." |
| 102 | fi |
| 103 | |
| 104 | log "Installing Obsidian..." |
| 105 | run "apt-get install -y '$DEB'" |
| 106 | |
| 107 | if (( ! DRY_RUN )) && command -v obsidian >/dev/null 2>&1; then |
| 108 | log "Installed: $(obsidian --version 2>/dev/null || echo "$TAG")" |
| 109 | fi |
| 110 | log "Done." |
| 111 |
| 1 | #!/usr/bin/env bash |
| 2 | # install-qbittorrent.sh — Install qBittorrent from the latest official GitHub release. |
| 3 | # Hardened: arch-aware, GitHub API token support, SHA-256 verification from the |
| 4 | # release asset digest, idempotent, --dry-run. |
| 5 | |
| 6 | set -euo pipefail |
| 7 | IFS=$'\n\t' |
| 8 | |
| 9 | readonly SCRIPT_NAME="${0##*/}" |
| 10 | DRY_RUN=0 |
| 11 | REPO="qbittorrent/qBittorrent" |
| 12 | |
| 13 | usage() { |
| 14 | cat <<EOF |
| 15 | Usage: sudo $SCRIPT_NAME [--dry-run] [--help] |
| 16 | |
| 17 | Resolves the latest release of qbittorrent/qBittorrent, downloads the official |
| 18 | Linux x86_64 AppImage, verifies its SHA-256 against the GitHub release asset |
| 19 | digest when available, and installs it system-wide. |
| 20 | |
| 21 | If \$GITHUB_TOKEN is set in the environment, it is used to authenticate the |
| 22 | GitHub API request (avoids rate limits). |
| 23 | |
| 24 | Options: |
| 25 | --dry-run Print actions without executing. |
| 26 | --help, -h Show this help. |
| 27 | EOF |
| 28 | } |
| 29 | |
| 30 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 31 | warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; } |
| 32 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 33 | run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; } |
| 34 | trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR |
| 35 | |
| 36 | while (( $# )); do |
| 37 | case "$1" in |
| 38 | --dry-run) DRY_RUN=1 ;; |
| 39 | -h|--help) usage; exit 0 ;; |
| 40 | *) die "Unknown argument: $1 (try --help)" ;; |
| 41 | esac |
| 42 | shift |
| 43 | done |
| 44 | |
| 45 | (( EUID == 0 )) || die "Must run as root. Try: sudo $SCRIPT_NAME" |
| 46 | |
| 47 | [[ -r /etc/os-release ]] || die "/etc/os-release not found." |
| 48 | # shellcheck disable=SC1091 |
| 49 | . /etc/os-release |
| 50 | case "${ID:-}:${ID_LIKE:-}" in |
| 51 | *ubuntu*|*debian*) : ;; |
| 52 | *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;; |
| 53 | esac |
| 54 | |
| 55 | ARCH="$(dpkg --print-architecture)" |
| 56 | [[ "$ARCH" == "amd64" ]] || die "qBittorrent publishes official Linux AppImages for x86_64/amd64 only (detected: $ARCH)." |
| 57 | log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH" |
| 58 | |
| 59 | export DEBIAN_FRONTEND=noninteractive |
| 60 | log "Installing prerequisites..." |
| 61 | run "apt-get update -qq" |
| 62 | run "apt-get install -y curl jq ca-certificates desktop-file-utils shared-mime-info hicolor-icon-theme" |
| 63 | |
| 64 | GH_HDRS=(-H "Accept: application/vnd.github+json") |
| 65 | [[ -n "${GITHUB_TOKEN:-}" ]] && GH_HDRS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}") |
| 66 | |
| 67 | log "Querying GitHub API for latest release of $REPO..." |
| 68 | RELEASE_JSON="$(curl -fsSL "${GH_HDRS[@]}" "https://api.github.com/repos/${REPO}/releases/latest")" |
| 69 | TAG="$(jq -r '.tag_name // empty' <<<"$RELEASE_JSON")" |
| 70 | [[ -n "$TAG" ]] || die "Could not parse latest release tag (rate limited? set GITHUB_TOKEN)." |
| 71 | VERSION="${TAG#release-}" |
| 72 | log "Latest release: $TAG" |
| 73 | |
| 74 | ASSET_REGEX='^qbittorrent-[0-9][^/]*_x86_64\.AppImage$' |
| 75 | DOWNLOAD_URL="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | select(.name | test("_lt20_") | not) | .browser_download_url' <<<"$RELEASE_JSON" | head -n1)" |
| 76 | ASSET_NAME="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | select(.name | test("_lt20_") | not) | .name' <<<"$RELEASE_JSON" | head -n1)" |
| 77 | EXPECTED_SHA="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | select(.name | test("_lt20_") | not) | (.digest // "") | sub("^sha256:"; "")' <<<"$RELEASE_JSON" | head -n1)" |
| 78 | [[ "$DOWNLOAD_URL" =~ ^https:// ]] || die "No standard x86_64 AppImage asset found in latest qBittorrent release." |
| 79 | |
| 80 | STAGE="$(mktemp -d -t qbittorrent.XXXXXX)" |
| 81 | trap 'rm -rf "$STAGE"' EXIT |
| 82 | APPIMAGE="$STAGE/$ASSET_NAME" |
| 83 | |
| 84 | log "Downloading $ASSET_NAME..." |
| 85 | run "curl -fL -o '$APPIMAGE' '$DOWNLOAD_URL'" |
| 86 | |
| 87 | if (( DRY_RUN )); then |
| 88 | [[ -n "$EXPECTED_SHA" && "$EXPECTED_SHA" != "null" ]] \ |
| 89 | && printf ' DRY-RUN: verify SHA-256 %s\n' "$EXPECTED_SHA" \ |
| 90 | || printf ' DRY-RUN: skip SHA-256 verification; no GitHub asset digest found\n' |
| 91 | printf ' DRY-RUN: install AppImage to /opt/qbittorrent/qbittorrent\n' |
| 92 | printf ' DRY-RUN: create /usr/local/bin/qbittorrent symlink\n' |
| 93 | printf ' DRY-RUN: install desktop launcher, icons, and MIME handlers\n' |
| 94 | log "Done." |
| 95 | exit 0 |
| 96 | fi |
| 97 | |
| 98 | if [[ -n "$EXPECTED_SHA" && "$EXPECTED_SHA" != "null" ]]; then |
| 99 | log "Verifying SHA-256..." |
| 100 | ACTUAL_SHA="$(sha256sum "$APPIMAGE" | awk '{print $1}')" |
| 101 | [[ "$EXPECTED_SHA" == "$ACTUAL_SHA" ]] || die "SHA-256 mismatch: expected=$EXPECTED_SHA actual=$ACTUAL_SHA" |
| 102 | log "SHA-256 ok." |
| 103 | else |
| 104 | warn "No SHA-256 asset digest in GitHub release metadata; skipping verification." |
| 105 | fi |
| 106 | |
| 107 | log "Installing qBittorrent AppImage..." |
| 108 | run "install -d -m 0755 /opt/qbittorrent" |
| 109 | run "install -m 0755 '$APPIMAGE' /opt/qbittorrent/qbittorrent" |
| 110 | run "ln -sfn /opt/qbittorrent/qbittorrent /usr/local/bin/qbittorrent" |
| 111 | |
| 112 | log "Extracting desktop metadata and icons..." |
| 113 | EXTRACT_DIR="$STAGE/extract" |
| 114 | run "install -d -m 0755 '$EXTRACT_DIR'" |
| 115 | ( |
| 116 | cd "$EXTRACT_DIR" |
| 117 | /opt/qbittorrent/qbittorrent --appimage-extract >/dev/null |
| 118 | ) |
| 119 | |
| 120 | run "install -d -m 0755 /usr/share/icons/hicolor/scalable/apps" |
| 121 | run "install -m 0644 '$EXTRACT_DIR/squashfs-root/usr/share/icons/hicolor/scalable/apps/qbittorrent.svg' /usr/share/icons/hicolor/scalable/apps/qbittorrent.svg" |
| 122 | for size in 16 22 24 32 36 48 64 72 96 128 192; do |
| 123 | src="$EXTRACT_DIR/squashfs-root/usr/share/icons/hicolor/${size}x${size}/apps/qbittorrent.png" |
| 124 | [[ -f "$src" ]] || continue |
| 125 | run "install -d -m 0755 '/usr/share/icons/hicolor/${size}x${size}/apps'" |
| 126 | run "install -m 0644 '$src' '/usr/share/icons/hicolor/${size}x${size}/apps/qbittorrent.png'" |
| 127 | done |
| 128 | |
| 129 | log "Installing desktop launcher..." |
| 130 | cat >/usr/share/applications/org.qbittorrent.qBittorrent.desktop <<EOF |
| 131 | [Desktop Entry] |
| 132 | Type=Application |
| 133 | Name=qBittorrent |
| 134 | GenericName=BitTorrent client |
| 135 | Comment=Download and share files over BitTorrent |
| 136 | Exec=/opt/qbittorrent/qbittorrent %U |
| 137 | Icon=qbittorrent |
| 138 | Terminal=false |
| 139 | Categories=Network;FileTransfer;P2P;Qt; |
| 140 | MimeType=application/x-bittorrent;x-scheme-handler/magnet; |
| 141 | StartupNotify=false |
| 142 | StartupWMClass=qbittorrent |
| 143 | Keywords=bittorrent;torrent;magnet;download;p2p; |
| 144 | SingleMainWindow=true |
| 145 | EOF |
| 146 | |
| 147 | run "desktop-file-validate /usr/share/applications/org.qbittorrent.qBittorrent.desktop" |
| 148 | run "update-desktop-database /usr/share/applications" |
| 149 | run "update-mime-database /usr/share/mime" |
| 150 | run "gtk-update-icon-cache -f -t /usr/share/icons/hicolor >/dev/null 2>&1 || true" |
| 151 | |
| 152 | if command -v xdg-mime >/dev/null 2>&1; then |
| 153 | run "xdg-mime default org.qbittorrent.qBittorrent.desktop application/x-bittorrent || true" |
| 154 | run "xdg-mime default org.qbittorrent.qBittorrent.desktop x-scheme-handler/magnet || true" |
| 155 | fi |
| 156 | |
| 157 | if command -v qbittorrent >/dev/null 2>&1; then |
| 158 | log "Installed: $(qbittorrent --version 2>/dev/null || echo "$VERSION")" |
| 159 | fi |
| 160 | log "Done." |
| 161 |
| 1 | #!/usr/bin/env bash |
| 2 | # install-screenshot-cleanup-cron.sh — Clear ~/Pictures/Screenshots every 5 minutes. |
| 3 | # Idempotent per-user crontab installer. |
| 4 | |
| 5 | set -euo pipefail |
| 6 | IFS=$'\n\t' |
| 7 | |
| 8 | readonly SCRIPT_NAME="${0##*/}" |
| 9 | readonly BEGIN_MARKER="# BEGIN managed by install-screenshot-cleanup-cron.sh" |
| 10 | readonly END_MARKER="# END managed by install-screenshot-cleanup-cron.sh" |
| 11 | DRY_RUN=0 |
| 12 | |
| 13 | usage() { |
| 14 | cat <<EOF |
| 15 | Usage: $SCRIPT_NAME [--dry-run] [--help] |
| 16 | |
| 17 | Installs a per-user cron job that deletes everything inside: |
| 18 | |
| 19 | \$HOME/Pictures/Screenshots |
| 20 | |
| 21 | every 5 minutes. The Screenshots directory itself is preserved. |
| 22 | |
| 23 | Options: |
| 24 | --dry-run Print actions without executing. |
| 25 | --help, -h Show this help. |
| 26 | EOF |
| 27 | } |
| 28 | |
| 29 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 30 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 31 | |
| 32 | while (( $# )); do |
| 33 | case "$1" in |
| 34 | --dry-run) DRY_RUN=1 ;; |
| 35 | -h|--help) usage; exit 0 ;; |
| 36 | *) die "Unknown argument: $1 (try --help)" ;; |
| 37 | esac |
| 38 | shift |
| 39 | done |
| 40 | |
| 41 | (( EUID != 0 )) || die "Do not run as root. Run as your normal desktop user so the cron job uses the correct \$HOME." |
| 42 | |
| 43 | for tool in crontab awk mktemp mkdir; do |
| 44 | command -v "$tool" >/dev/null 2>&1 || die "Missing required tool: $tool" |
| 45 | done |
| 46 | |
| 47 | SCREENSHOT_DIR="$HOME/Pictures/Screenshots" |
| 48 | CRON_LINE="*/5 * * * * find \"$SCREENSHOT_DIR\" -mindepth 1 -delete" |
| 49 | STAGE="$(mktemp -d -t screenshot-cron.XXXXXX)" |
| 50 | trap 'rm -rf "$STAGE"' EXIT |
| 51 | |
| 52 | CURRENT="$STAGE/current" |
| 53 | NEXT="$STAGE/next" |
| 54 | crontab -l >"$CURRENT" 2>/dev/null || true |
| 55 | |
| 56 | awk -v begin="$BEGIN_MARKER" -v end="$END_MARKER" ' |
| 57 | $0 == begin { skip = 1; next } |
| 58 | $0 == end { skip = 0; next } |
| 59 | skip { next } |
| 60 | $0 == "# Delete screenshot contents every 5 minutes" { next } |
| 61 | index($0, "Pictures/Screenshots") && index($0, "-mindepth 1") && index($0, "-delete") { next } |
| 62 | { print } |
| 63 | ' "$CURRENT" >"$NEXT" |
| 64 | |
| 65 | { |
| 66 | printf '%s\n' "$BEGIN_MARKER" |
| 67 | printf '# Delete screenshot contents every 5 minutes\n' |
| 68 | printf '%s\n' "$CRON_LINE" |
| 69 | printf '%s\n' "$END_MARKER" |
| 70 | } >>"$NEXT" |
| 71 | |
| 72 | if (( DRY_RUN )); then |
| 73 | log "Would ensure directory exists: $SCREENSHOT_DIR" |
| 74 | log "Would install this user crontab:" |
| 75 | sed 's/^/ /' "$NEXT" |
| 76 | exit 0 |
| 77 | fi |
| 78 | |
| 79 | mkdir -p "$SCREENSHOT_DIR" |
| 80 | crontab "$NEXT" |
| 81 | |
| 82 | log "Installed screenshot cleanup cron job:" |
| 83 | printf ' %s\n' "$CRON_LINE" |
| 84 | log "Done." |
| 85 |
| 1 | #!/usr/bin/env bash |
| 2 | # install-telegram.sh — Install Telegram Desktop from the latest official Linux build. |
| 3 | # Hardened: official download endpoint, arch-aware, idempotent, --dry-run. |
| 4 | |
| 5 | set -euo pipefail |
| 6 | IFS=$'\n\t' |
| 7 | |
| 8 | readonly SCRIPT_NAME="${0##*/}" |
| 9 | DRY_RUN=0 |
| 10 | DOWNLOAD_URL="https://telegram.org/dl/desktop/linux" |
| 11 | |
| 12 | usage() { |
| 13 | cat <<EOF |
| 14 | Usage: sudo $SCRIPT_NAME [--dry-run] [--help] |
| 15 | |
| 16 | Downloads Telegram Desktop from Telegram's official latest Linux endpoint, |
| 17 | installs it to /opt/Telegram, creates /usr/local/bin/telegram-desktop, and |
| 18 | installs a desktop launcher. |
| 19 | |
| 20 | Options: |
| 21 | --dry-run Print actions without executing. |
| 22 | --help, -h Show this help. |
| 23 | EOF |
| 24 | } |
| 25 | |
| 26 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 27 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 28 | run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; } |
| 29 | trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR |
| 30 | |
| 31 | while (( $# )); do |
| 32 | case "$1" in |
| 33 | --dry-run) DRY_RUN=1 ;; |
| 34 | -h|--help) usage; exit 0 ;; |
| 35 | *) die "Unknown argument: $1 (try --help)" ;; |
| 36 | esac |
| 37 | shift |
| 38 | done |
| 39 | |
| 40 | (( EUID == 0 )) || die "Must run as root. Try: sudo $SCRIPT_NAME" |
| 41 | |
| 42 | [[ -r /etc/os-release ]] || die "/etc/os-release not found." |
| 43 | # shellcheck disable=SC1091 |
| 44 | . /etc/os-release |
| 45 | case "${ID:-}:${ID_LIKE:-}" in |
| 46 | *ubuntu*|*debian*) : ;; |
| 47 | *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;; |
| 48 | esac |
| 49 | |
| 50 | MACHINE="$(uname -m)" |
| 51 | case "$MACHINE" in |
| 52 | x86_64|amd64) : ;; |
| 53 | *) die "Telegram's official Linux desktop tarball targets x86_64 (detected: $MACHINE)." ;; |
| 54 | esac |
| 55 | log "Detected: ${PRETTY_NAME:-unknown}, arch: $MACHINE" |
| 56 | |
| 57 | export DEBIAN_FRONTEND=noninteractive |
| 58 | log "Installing prerequisites..." |
| 59 | run "apt-get update -qq" |
| 60 | run "apt-get install -y curl ca-certificates tar xz-utils desktop-file-utils hicolor-icon-theme" |
| 61 | |
| 62 | STAGE="$(mktemp -d -t telegram.XXXXXX)" |
| 63 | trap 'rm -rf "$STAGE"' EXIT |
| 64 | ARCHIVE="$STAGE/telegram.tar.xz" |
| 65 | |
| 66 | log "Downloading latest Telegram Desktop..." |
| 67 | run "curl -fL -o '$ARCHIVE' '$DOWNLOAD_URL'" |
| 68 | |
| 69 | if (( DRY_RUN )); then |
| 70 | printf ' DRY-RUN: extract %s\n' "$ARCHIVE" |
| 71 | printf ' DRY-RUN: install Telegram to /opt/Telegram\n' |
| 72 | printf ' DRY-RUN: create /usr/local/bin/telegram-desktop symlink and desktop launcher\n' |
| 73 | log "Done." |
| 74 | exit 0 |
| 75 | fi |
| 76 | |
| 77 | log "Extracting Telegram..." |
| 78 | run "tar -xJf '$ARCHIVE' -C '$STAGE'" |
| 79 | [[ -x "$STAGE/Telegram/Telegram" ]] || die "Unexpected archive layout: Telegram/Telegram not found." |
| 80 | |
| 81 | log "Installing Telegram..." |
| 82 | run "rm -rf /opt/Telegram" |
| 83 | run "install -d -m 0755 /opt" |
| 84 | run "cp -a '$STAGE/Telegram' /opt/Telegram" |
| 85 | run "ln -sfn /opt/Telegram/Telegram /usr/local/bin/telegram-desktop" |
| 86 | |
| 87 | run "install -d -m 0755 /usr/share/applications /usr/share/icons/hicolor/256x256/apps" |
| 88 | if [[ -f /opt/Telegram/telegram.png ]]; then |
| 89 | run "install -m 0644 /opt/Telegram/telegram.png /usr/share/icons/hicolor/256x256/apps/telegram.png" |
| 90 | fi |
| 91 | |
| 92 | cat >/usr/share/applications/telegram-desktop.desktop <<'EOF' |
| 93 | [Desktop Entry] |
| 94 | Type=Application |
| 95 | Name=Telegram Desktop |
| 96 | GenericName=Messenger |
| 97 | Comment=Official Telegram desktop messaging app |
| 98 | Exec=/opt/Telegram/Telegram -- %u |
| 99 | Icon=telegram |
| 100 | Terminal=false |
| 101 | Categories=Network;InstantMessaging;Chat; |
| 102 | MimeType=x-scheme-handler/tg; |
| 103 | StartupNotify=false |
| 104 | StartupWMClass=TelegramDesktop |
| 105 | Keywords=telegram;chat;messenger; |
| 106 | EOF |
| 107 | |
| 108 | run "desktop-file-validate /usr/share/applications/telegram-desktop.desktop" |
| 109 | run "update-desktop-database /usr/share/applications" |
| 110 | run "gtk-update-icon-cache -f -t /usr/share/icons/hicolor >/dev/null 2>&1 || true" |
| 111 | if command -v xdg-mime >/dev/null 2>&1; then |
| 112 | run "xdg-mime default telegram-desktop.desktop x-scheme-handler/tg || true" |
| 113 | fi |
| 114 | |
| 115 | log "Installed: $(/opt/Telegram/Telegram --version 2>/dev/null || echo Telegram Desktop)" |
| 116 | log "Done." |
| 117 |
| 1 | #!/usr/bin/env bash |
| 2 | set -euo pipefail |
| 3 | |
| 4 | readonly SCRIPT_NAME="${0##*/}" |
| 5 | readonly BACKUP_URL='https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD/backup-thunderbird-to-synology.sh' |
| 6 | readonly CRON_MARKER='# thunderbird-synology-backup' |
| 7 | readonly CRON_ENTRY="0 * * * * /usr/bin/bash -o pipefail -c '/usr/bin/curl --proto \"=https\" --tlsv1.2 -fsSL --max-time 60 \"$BACKUP_URL\" | /usr/bin/bash' >> \"\$HOME/.local/state/thunderbird-cron.log\" 2>&1 $CRON_MARKER" |
| 8 | DRY_RUN=0 |
| 9 | |
| 10 | usage() { |
| 11 | cat <<EOF |
| 12 | Usage: $SCRIPT_NAME [--dry-run] [--help] |
| 13 | |
| 14 | Installs an hourly user cron job that fetches and executes the hosted |
| 15 | Thunderbird-to-Synology backup script without storing the script locally. |
| 16 | |
| 17 | Options: |
| 18 | --dry-run Print the cron entry without installing it. |
| 19 | --help, -h Show this help. |
| 20 | EOF |
| 21 | } |
| 22 | |
| 23 | die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } |
| 24 | |
| 25 | while (( $# )); do |
| 26 | case "$1" in |
| 27 | --dry-run) DRY_RUN=1 ;; |
| 28 | -h|--help) usage; exit 0 ;; |
| 29 | *) die "Unknown argument: $1 (try --help)" ;; |
| 30 | esac |
| 31 | shift |
| 32 | done |
| 33 | |
| 34 | (( EUID != 0 )) || die "Do not run as root. Run as your normal user." |
| 35 | |
| 36 | for tool in /usr/bin/bash /usr/bin/curl crontab grep; do |
| 37 | if [[ "$tool" == /* ]]; then |
| 38 | [[ -x "$tool" ]] || die "Missing required tool: $tool" |
| 39 | else |
| 40 | command -v "$tool" >/dev/null 2>&1 || die "Missing required tool: $tool" |
| 41 | fi |
| 42 | done |
| 43 | |
| 44 | if (( DRY_RUN )); then |
| 45 | printf '%s\n' "$CRON_ENTRY" |
| 46 | exit 0 |
| 47 | fi |
| 48 | |
| 49 | mkdir -p "$HOME/.local/state" |
| 50 | |
| 51 | current_crontab="$(crontab -l 2>/dev/null || true)" |
| 52 | filtered_crontab="$(printf '%s\n' "$current_crontab" | grep -Fv -e "$CRON_MARKER" -e 'backup-thunderbird-to-synology.sh' || true)" |
| 53 | |
| 54 | if [[ -n "$filtered_crontab" ]]; then |
| 55 | printf '%s\n%s\n' "$filtered_crontab" "$CRON_ENTRY" | crontab - |
| 56 | else |
| 57 | printf '%s\n' "$CRON_ENTRY" | crontab - |
| 58 | fi |
| 59 | |
| 60 | installed_count="$(crontab -l | grep -Fxc "$CRON_ENTRY" || true)" |
| 61 | [[ "$installed_count" == 1 ]] || die "Unable to verify the hourly cron entry." |
| 62 | |
| 63 | printf 'Installed hourly Thunderbird backup cron job.\n' |
| 64 | printf '%s\n' "$CRON_ENTRY" |
| 65 |
| 1 | #!/usr/bin/env bash |
| 2 | # install-thunderbird.sh — Install Thunderbird. |
| 3 | # On Ubuntu: uses the Mozilla Team PPA (with APT pinning so Snap transition is bypassed). |
| 4 | # On Debian: uses the regular Debian package (no PPA available). |
| 5 | # Hardened: distro-detect, idempotent, --dry-run. |
| 6 | |
| 7 | set -euo pipefail |
| 8 | IFS=$'\n\t' |
| 9 | |
| 10 | readonly SCRIPT_NAME="${0##*/}" |
| 11 | DRY_RUN=0 |
| 12 | |
| 13 | usage() { |
| 14 | cat <<EOF |
| 15 | Usage: sudo $SCRIPT_NAME [--dry-run] [--help] |
| 16 | |
| 17 | Installs Thunderbird. |
| 18 | - Ubuntu: removes Snap version (if any), adds Mozilla Team PPA, pins it, |
| 19 | and installs the .deb. This avoids the Ubuntu snap transition wrapper. |
| 20 | - Debian: installs from the standard Debian repos. |
| 21 | |
| 22 | Options: |
| 23 | --dry-run Print actions without executing. |
| 24 | --help, -h Show this help. |
| 25 | EOF |
| 26 | } |
| 27 | |
| 28 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 29 | warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; } |
| 30 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 31 | run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; } |
| 32 | trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR |
| 33 | |
| 34 | while (( $# )); do |
| 35 | case "$1" in |
| 36 | --dry-run) DRY_RUN=1 ;; |
| 37 | -h|--help) usage; exit 0 ;; |
| 38 | *) die "Unknown argument: $1 (try --help)" ;; |
| 39 | esac |
| 40 | shift |
| 41 | done |
| 42 | |
| 43 | (( EUID == 0 )) || die "Must run as root. Try: sudo $SCRIPT_NAME" |
| 44 | |
| 45 | [[ -r /etc/os-release ]] || die "/etc/os-release not found." |
| 46 | # shellcheck disable=SC1091 |
| 47 | . /etc/os-release |
| 48 | log "Detected: ${PRETTY_NAME:-unknown} (id=${ID:-?})" |
| 49 | |
| 50 | export DEBIAN_FRONTEND=noninteractive |
| 51 | |
| 52 | case "${ID:-}" in |
| 53 | ubuntu) |
| 54 | log "Removing Thunderbird Snap (if present)..." |
| 55 | if command -v snap >/dev/null 2>&1; then |
| 56 | run "snap remove --purge thunderbird >/dev/null 2>&1 || true" |
| 57 | fi |
| 58 | |
| 59 | log "Installing prerequisites..." |
| 60 | run "apt-get update -qq" |
| 61 | run "apt-get install -y software-properties-common" |
| 62 | |
| 63 | log "Adding Mozilla Team PPA..." |
| 64 | PPA_LIST=/etc/apt/sources.list.d/mozillateam-ubuntu-ppa-*.list |
| 65 | # shellcheck disable=SC2086 |
| 66 | if ! ls $PPA_LIST >/dev/null 2>&1; then |
| 67 | run "add-apt-repository -y ppa:mozillateam/ppa" |
| 68 | else |
| 69 | log "Mozilla Team PPA already configured." |
| 70 | fi |
| 71 | |
| 72 | PIN=/etc/apt/preferences.d/mozillateamppa |
| 73 | log "Pinning Mozilla Team PPA so .deb wins over Snap transition wrapper..." |
| 74 | if [[ ! -f "$PIN" ]] || ! grep -q 'release o=LP-PPA-mozillateam' "$PIN"; then |
| 75 | if (( DRY_RUN )); then |
| 76 | printf ' DRY-RUN: write %s\n' "$PIN" |
| 77 | else |
| 78 | cat >"$PIN" <<'EOF' |
| 79 | Package: thunderbird* |
| 80 | Pin: release o=LP-PPA-mozillateam |
| 81 | Pin-Priority: 1001 |
| 82 | EOF |
| 83 | fi |
| 84 | fi |
| 85 | |
| 86 | log "Installing thunderbird from PPA..." |
| 87 | run "apt-get update -qq" |
| 88 | run "apt-get install -y --allow-downgrades thunderbird" |
| 89 | ;; |
| 90 | |
| 91 | debian) |
| 92 | log "Installing thunderbird from Debian repos..." |
| 93 | run "apt-get update -qq" |
| 94 | run "apt-get install -y thunderbird" |
| 95 | ;; |
| 96 | |
| 97 | *) |
| 98 | die "Unsupported distro: ${PRETTY_NAME:-unknown}. Supported: ubuntu, debian." |
| 99 | ;; |
| 100 | esac |
| 101 | |
| 102 | log "Done." |
| 103 |
| 1 | #!/usr/bin/env bash |
| 2 | # install-vscode.sh — Install Visual Studio Code from Microsoft's APT repository. |
| 3 | # Hardened: arch-aware, signed-by keyring, idempotent, --dry-run. |
| 4 | |
| 5 | set -euo pipefail |
| 6 | IFS=$'\n\t' |
| 7 | |
| 8 | readonly SCRIPT_NAME="${0##*/}" |
| 9 | DRY_RUN=0 |
| 10 | INSIDERS=0 |
| 11 | |
| 12 | usage() { |
| 13 | cat <<EOF |
| 14 | Usage: sudo $SCRIPT_NAME [--dry-run] [--insiders] [--help] |
| 15 | |
| 16 | Configures the official Microsoft 'vscode' APT repository (signed-by keyring) |
| 17 | and installs Visual Studio Code. Idempotent: safe to re-run. |
| 18 | |
| 19 | Options: |
| 20 | --insiders Install the 'code-insiders' build instead of stable 'code'. |
| 21 | --dry-run Print actions without executing. |
| 22 | --help, -h Show this help. |
| 23 | EOF |
| 24 | } |
| 25 | |
| 26 | log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; } |
| 27 | warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; } |
| 28 | die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; } |
| 29 | run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; } |
| 30 | trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR |
| 31 | |
| 32 | while (( $# )); do |
| 33 | case "$1" in |
| 34 | --dry-run) DRY_RUN=1 ;; |
| 35 | --insiders) INSIDERS=1 ;; |
| 36 | -h|--help) usage; exit 0 ;; |
| 37 | *) die "Unknown argument: $1 (try --help)" ;; |
| 38 | esac |
| 39 | shift |
| 40 | done |
| 41 | |
| 42 | (( EUID == 0 )) || die "Must run as root. Try: sudo $SCRIPT_NAME" |
| 43 | |
| 44 | [[ -r /etc/os-release ]] || die "/etc/os-release not found." |
| 45 | # shellcheck disable=SC1091 |
| 46 | . /etc/os-release |
| 47 | case "${ID:-}:${ID_LIKE:-}" in |
| 48 | *ubuntu*|*debian*) : ;; |
| 49 | *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;; |
| 50 | esac |
| 51 | |
| 52 | ARCH="$(dpkg --print-architecture)" |
| 53 | case "$ARCH" in |
| 54 | amd64|arm64|armhf) : ;; |
| 55 | *) die "VS Code is published for amd64/arm64/armhf (detected: $ARCH)." ;; |
| 56 | esac |
| 57 | log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH" |
| 58 | |
| 59 | export DEBIAN_FRONTEND=noninteractive |
| 60 | |
| 61 | KEYRING=/etc/apt/keyrings/packages.microsoft.gpg |
| 62 | SOURCES=/etc/apt/sources.list.d/vscode.list |
| 63 | PKG=$([[ $INSIDERS -eq 1 ]] && echo code-insiders || echo code) |
| 64 | |
| 65 | log "Installing prerequisites..." |
| 66 | run "apt-get update -qq" |
| 67 | run "apt-get install -y wget gpg apt-transport-https ca-certificates" |
| 68 | |
| 69 | log "Configuring Microsoft vscode APT repository..." |
| 70 | run "install -d -m 0755 /etc/apt/keyrings" |
| 71 | if [[ ! -s "$KEYRING" ]]; then |
| 72 | run "wget -qO- https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o '$KEYRING'" |
| 73 | run "chmod 0644 '$KEYRING'" |
| 74 | fi |
| 75 | |
| 76 | DESIRED_SRC="deb [arch=amd64,arm64,armhf signed-by=${KEYRING}] https://packages.microsoft.com/repos/code stable main" |
| 77 | if [[ ! -f "$SOURCES" ]] || ! grep -qxF "$DESIRED_SRC" "$SOURCES"; then |
| 78 | run "printf '%s\n' '$DESIRED_SRC' > '$SOURCES'" |
| 79 | fi |
| 80 | |
| 81 | log "Installing $PKG..." |
| 82 | run "apt-get update -qq" |
| 83 | run "apt-get install -y '$PKG'" |
| 84 | |
| 85 | if (( ! DRY_RUN )) && command -v "$PKG" >/dev/null 2>&1; then |
| 86 | log "Installed: $("$PKG" --version 2>/dev/null | head -n1 || echo "$PKG")" |
| 87 | fi |
| 88 | log "Done." |
| 89 |
| 1 | #!/usr/bin/env bash |
| 2 | # menu.sh — Ubuntu/Debian Setup Manager |
| 3 | # |
| 4 | # Interactive menu that fetches and runs the hardened install scripts in this |
| 5 | # Opengist. Each script is downloaded to a temp file (not blindly piped to bash) |
| 6 | # and executed with the appropriate privilege level for that script: |
| 7 | # |
| 8 | # - "sudo" mode for system-wide installers (apt, /etc, /usr/local/bin) |
| 9 | # - "user" mode for per-user installers that must NOT run as root |
| 10 | # (gsettings, ~/.local, JetBrains Toolbox, fonts) |
| 11 | # |
| 12 | # Usage: |
| 13 | # bash -c "$(curl -fsSL https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD/menu.sh)" |
| 14 | # |
| 15 | # Or save and run locally: |
| 16 | # curl -fsSLO https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD/menu.sh |
| 17 | # bash menu.sh |
| 18 | |
| 19 | # Note: NOT using `set -e` because we want the menu loop to survive a failed |
| 20 | # sub-script. We do use -u and pipefail to catch real bugs in this file. |
| 21 | set -uo pipefail |
| 22 | IFS=$'\n\t' |
| 23 | |
| 24 | readonly SCRIPT_NAME="${0##*/}" |
| 25 | readonly BASE_URL='https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD' |
| 26 | |
| 27 | log() { printf '\033[1;34m[menu]\033[0m %s\n' "$*"; } |
| 28 | warn() { printf '\033[1;33m[menu] WARN:\033[0m %s\n' "$*" >&2; } |
| 29 | err() { printf '\033[1;31m[menu] ERROR:\033[0m %s\n' "$*" >&2; } |
| 30 | hr() { printf '%s\n' "------------------------------------------------------"; } |
| 31 | |
| 32 | # Refuse to run as root: per-user scripts (jetbrains, fonts, ibus) need a real |
| 33 | # desktop user. Sub-scripts elevate via sudo on their own. |
| 34 | if (( EUID == 0 )); then |
| 35 | err "Don't run $SCRIPT_NAME as root. Run as your normal user — it will call sudo for installers that need it." |
| 36 | exit 1 |
| 37 | fi |
| 38 | |
| 39 | # Sanity-check tools we depend on. |
| 40 | for tool in curl bash mktemp; do |
| 41 | command -v "$tool" >/dev/null 2>&1 || { err "Missing required tool: $tool"; exit 1; } |
| 42 | done |
| 43 | |
| 44 | # Distro check (warn-only; sub-scripts enforce strictly). |
| 45 | if [[ -r /etc/os-release ]]; then |
| 46 | # shellcheck disable=SC1091 |
| 47 | . /etc/os-release |
| 48 | case "${ID:-}:${ID_LIKE:-}" in |
| 49 | *ubuntu*|*debian*) : ;; |
| 50 | *) warn "Detected ${PRETTY_NAME:-unknown}. These installers target Debian/Ubuntu; some will refuse to run." ;; |
| 51 | esac |
| 52 | fi |
| 53 | |
| 54 | # Cache sudo credentials up-front so sub-scripts that elevate don't keep |
| 55 | # re-prompting in the middle of a multi-task run. |
| 56 | prime_sudo() { |
| 57 | if ! sudo -n true 2>/dev/null; then |
| 58 | log "Caching sudo credentials (you may be prompted)..." |
| 59 | sudo -v || { err "sudo authentication failed."; return 1; } |
| 60 | fi |
| 61 | # Keep sudo timestamp refreshed in the background while the menu runs. |
| 62 | ( while true; do sudo -n true 2>/dev/null; sleep 60; done ) & |
| 63 | SUDO_KEEPALIVE_PID=$! |
| 64 | trap 'kill "${SUDO_KEEPALIVE_PID:-}" 2>/dev/null || true' EXIT |
| 65 | } |
| 66 | |
| 67 | # run_script <filename> <mode> |
| 68 | # mode: "sudo" (run as root via sudo) or "user" (run as current user) |
| 69 | # Returns: 0 on success, non-zero on failure (does NOT exit the menu). |
| 70 | run_script() { |
| 71 | local name="$1" mode="$2" |
| 72 | local url="$BASE_URL/$name" |
| 73 | local tmp |
| 74 | tmp="$(mktemp -t "${name%.sh}.XXXXXX.sh")" || { err "mktemp failed"; return 1; } |
| 75 | |
| 76 | log "Fetching $name..." |
| 77 | if ! curl -fsSL --max-time 60 --retry 2 "$url" -o "$tmp"; then |
| 78 | err "Download failed: $url" |
| 79 | rm -f "$tmp" |
| 80 | return 1 |
| 81 | fi |
| 82 | if [[ ! -s "$tmp" ]]; then |
| 83 | err "Downloaded $name is empty." |
| 84 | rm -f "$tmp" |
| 85 | return 1 |
| 86 | fi |
| 87 | # Cheap sanity: confirm it looks like a shell script. |
| 88 | if ! head -n1 "$tmp" | grep -qE '^#!.*sh'; then |
| 89 | warn "$name doesn't start with a shebang; proceeding anyway." |
| 90 | fi |
| 91 | chmod +x "$tmp" |
| 92 | |
| 93 | local rc=0 |
| 94 | if [[ "$mode" == "sudo" ]]; then |
| 95 | sudo bash "$tmp" || rc=$? |
| 96 | else |
| 97 | bash "$tmp" || rc=$? |
| 98 | fi |
| 99 | rm -f "$tmp" |
| 100 | if (( rc != 0 )); then |
| 101 | err "$name exited with status $rc" |
| 102 | fi |
| 103 | return "$rc" |
| 104 | } |
| 105 | |
| 106 | # Catalog: number | category | label | script-filename | mode | include-in-all |
| 107 | # (Edit here to add/remove options — the menu loop is data-driven.) |
| 108 | OPTIONS=( |
| 109 | "1|Browsers & Mail|Install Google Chrome|install-chrome.sh|sudo|yes" |
| 110 | "2|Browsers & Mail|Install Firefox (Mozilla APT)|install-firefox.sh|sudo|yes" |
| 111 | "3|Browsers & Mail|Install Thunderbird|install-thunderbird.sh|sudo|yes" |
| 112 | "4|Communication|Install Telegram Desktop|install-telegram.sh|sudo|yes" |
| 113 | "5|Communication|Install Discord|install-discord.sh|sudo|yes" |
| 114 | "6|Files, Downloads & Storage|Install LocalSend|install-localsend.sh|sudo|yes" |
| 115 | "7|Files, Downloads & Storage|Install qBittorrent (latest AppImage)|install-qbittorrent.sh|sudo|yes" |
| 116 | "8|Files, Downloads & Storage|Mount Synology Network Drive|install-network_drive.sh|sudo|yes" |
| 117 | "9|Productivity & Security|Install LibreOffice (latest stable .deb)|install-libreoffice.sh|sudo|yes" |
| 118 | "10|Productivity & Security|Install Obsidian|install-obsidian.sh|sudo|yes" |
| 119 | "11|Productivity & Security|Install MarkText (Markdown editor)|install-marktext.sh|sudo|yes" |
| 120 | "12|Productivity & Security|Install draw.io Desktop|install-drawio.sh|sudo|yes" |
| 121 | "13|Productivity & Security|Install Espanso (text expander)|install-espanso.sh|sudo|yes" |
| 122 | "14|Productivity & Security|Install 1Password|install-1password.sh|sudo|yes" |
| 123 | "15|Development Tools|Install Visual Studio Code|install-vscode.sh|sudo|yes" |
| 124 | "16|Development Tools|Install JetBrains Toolbox|install-jetbrains-toolbox.sh|user|yes" |
| 125 | "17|Development Tools|Install Bruno (API client)|install-bruno.sh|sudo|yes" |
| 126 | "18|Development Tools|Install IPATool|install-ipatool.sh|sudo|yes" |
| 127 | "19|Desktop & Localization|Install IBus Intelligent Pinyin|install-ibus-pinyin.sh|user|yes" |
| 128 | "20|Desktop & Localization|Install Nerd Fonts|install-font.sh|user|yes" |
| 129 | "21|System & Maintenance|Install Screenshots Cleanup Cron|install-screenshot-cleanup-cron.sh|user|yes" |
| 130 | "22|System & Maintenance|Fix Dual-Boot Time (RTC to UTC)|timedatectl-fix.sh|sudo|yes" |
| 131 | "23|System & Maintenance|Disable GRUB Countdown Timer|disable-grub-countdown.sh|sudo|yes" |
| 132 | "24|Backup|Install Hourly Thunderbird Synology Backup|install-thunderbird-backup-cron.sh|user|no" |
| 133 | "26|System & Maintenance|Update Installed Software|update-all|group|no" |
| 134 | ) |
| 135 | |
| 136 | # Software installers that are safe to re-run when their software is already |
| 137 | # installed. Configuration, maintenance, backup, and sync tasks are excluded. |
| 138 | SOFTWARE_NUMS=(1 2 3 4 5 6 7 9 10 11 12 13 14 15 16 17 18 19 20) |
| 139 | |
| 140 | package_is_installed() { |
| 141 | [[ "$(dpkg-query -W -f='${db:Status-Abbrev}' "$1" 2>/dev/null || true)" == "ii " ]] |
| 142 | } |
| 143 | |
| 144 | package_pattern_is_installed() { |
| 145 | local status |
| 146 | |
| 147 | while IFS= read -r status; do |
| 148 | [[ "$status" == "ii " ]] && return 0 |
| 149 | done < <(dpkg-query -W -f='${db:Status-Abbrev}\n' "$1" 2>/dev/null || true) |
| 150 | return 1 |
| 151 | } |
| 152 | |
| 153 | software_is_installed() { |
| 154 | local choice="$1" |
| 155 | |
| 156 | case "$choice" in |
| 157 | 1) package_is_installed google-chrome-stable ;; |
| 158 | 2) package_is_installed firefox ;; |
| 159 | 3) package_is_installed thunderbird ;; |
| 160 | 4) [[ -x /opt/Telegram/Telegram ]] ;; |
| 161 | 5) package_is_installed discord ;; |
| 162 | 6) command -v localsend_app >/dev/null 2>&1 ;; |
| 163 | 7) [[ -x /opt/qbittorrent/qbittorrent ]] ;; |
| 164 | 9) package_pattern_is_installed 'libreoffice*' ;; |
| 165 | 10) command -v obsidian >/dev/null 2>&1 ;; |
| 166 | 11) command -v marktext >/dev/null 2>&1 ;; |
| 167 | 12) command -v drawio >/dev/null 2>&1 ;; |
| 168 | 13) command -v espanso >/dev/null 2>&1 ;; |
| 169 | 14) package_is_installed 1password ;; |
| 170 | 15) package_is_installed code ;; |
| 171 | 16) [[ -x "$HOME/.local/share/JetBrains/Toolbox/bin/jetbrains-toolbox" ]] ;; |
| 172 | 17) package_is_installed bruno ;; |
| 173 | 18) [[ -x /usr/local/bin/ipatool ]] ;; |
| 174 | 19) package_is_installed ibus-libpinyin ;; |
| 175 | 20) |
| 176 | compgen -G "$HOME/.local/share/fonts/UbuntuSans/*.[ot]tf" >/dev/null && |
| 177 | compgen -G "$HOME/.local/share/fonts/JetBrainsMonoNerdFont/*.[ot]tf" >/dev/null |
| 178 | ;; |
| 179 | *) return 1 ;; |
| 180 | esac |
| 181 | } |
| 182 | |
| 183 | print_menu() { |
| 184 | local entry num category label filename mode include_in_all |
| 185 | local last_category="" note display_label |
| 186 | |
| 187 | hr |
| 188 | echo " Ubuntu / Debian Setup Manager" |
| 189 | hr |
| 190 | for entry in "${OPTIONS[@]}"; do |
| 191 | IFS='|' read -r num category label filename mode include_in_all <<< "$entry" |
| 192 | |
| 193 | if [[ "$category" != "$last_category" ]]; then |
| 194 | printf '%s\n' "--- [ $category ] ---" |
| 195 | last_category="$category" |
| 196 | fi |
| 197 | |
| 198 | note="" |
| 199 | if [[ "$mode" == "group" ]]; then |
| 200 | note="updates installed software from options 1-7 and 9-20" |
| 201 | elif [[ "$mode" == "user" ]]; then |
| 202 | note="runs as you, not root" |
| 203 | fi |
| 204 | if [[ "$include_in_all" != "yes" && "$mode" != "group" ]]; then |
| 205 | [[ -n "$note" ]] && note+="; " |
| 206 | note+="not in ALL" |
| 207 | fi |
| 208 | |
| 209 | display_label="$label" |
| 210 | [[ -n "$note" ]] && display_label+=" ($note)" |
| 211 | printf '%4s) %s\n' "$num" "$display_label" |
| 212 | done |
| 213 | hr |
| 214 | echo " 0) Run ALL setup options (1-23)" |
| 215 | echo " -1) Exit" |
| 216 | hr |
| 217 | } |
| 218 | |
| 219 | # Resolve a numeric choice to its catalog entry; print "name|mode" to stdout. |
| 220 | resolve_choice() { |
| 221 | local want="$1" entry num category label filename mode include_in_all |
| 222 | for entry in "${OPTIONS[@]}"; do |
| 223 | IFS='|' read -r num category label filename mode include_in_all <<< "$entry" |
| 224 | if [[ "$num" == "$want" ]]; then |
| 225 | printf '%s|%s' "$filename" "$mode" |
| 226 | return 0 |
| 227 | fi |
| 228 | done |
| 229 | return 1 |
| 230 | } |
| 231 | |
| 232 | ALL_NUMS=() |
| 233 | for entry in "${OPTIONS[@]}"; do |
| 234 | IFS='|' read -r num category label filename mode include_in_all <<< "$entry" |
| 235 | [[ "$include_in_all" == "yes" ]] && ALL_NUMS+=("$num") |
| 236 | done |
| 237 | |
| 238 | prime_sudo || exit 1 |
| 239 | |
| 240 | while true; do |
| 241 | print_menu |
| 242 | read -rp "Enter choices separated by spaces (e.g., 1 7 12), 0 for ALL, -1 to exit: " choices </dev/tty || { |
| 243 | echo; log "Input closed; exiting." |
| 244 | break |
| 245 | } |
| 246 | |
| 247 | # Trim whitespace |
| 248 | choices="${choices##[[:space:]]}" |
| 249 | choices="${choices%%[[:space:]]}" |
| 250 | |
| 251 | if [[ -z "$choices" ]]; then |
| 252 | continue |
| 253 | fi |
| 254 | |
| 255 | if [[ "$choices" == "-1" ]]; then |
| 256 | log "Exiting." |
| 257 | break |
| 258 | fi |
| 259 | |
| 260 | selected_choices=() |
| 261 | |
| 262 | # Expand "0" to all options |
| 263 | if [[ "$choices" == "0" ]]; then |
| 264 | selected_choices=("${ALL_NUMS[@]}") |
| 265 | else |
| 266 | IFS=$' \t\n' read -r -a selected_choices <<< "$choices" |
| 267 | fi |
| 268 | |
| 269 | # Validate every token first; reject the whole batch if any token is bogus, |
| 270 | # so the user sees the problem before any work starts. |
| 271 | bad="" |
| 272 | for c in "${selected_choices[@]}"; do |
| 273 | if ! [[ "$c" =~ ^-?[0-9]+$ ]] || ! resolve_choice "$c" >/dev/null; then |
| 274 | bad+=" $c" |
| 275 | fi |
| 276 | done |
| 277 | if [[ -n "$bad" ]]; then |
| 278 | err "Invalid option(s):$bad" |
| 279 | sleep 1 |
| 280 | continue |
| 281 | fi |
| 282 | |
| 283 | failures=() |
| 284 | for c in "${selected_choices[@]}"; do |
| 285 | if [[ "$c" == "26" ]]; then |
| 286 | hr |
| 287 | log "[26] Updating installed software..." |
| 288 | for software_choice in "${SOFTWARE_NUMS[@]}"; do |
| 289 | spec="$(resolve_choice "$software_choice")" |
| 290 | name="${spec%|*}" |
| 291 | mode="${spec##*|}" |
| 292 | if ! software_is_installed "$software_choice"; then |
| 293 | log "[$software_choice] Skipping $name (not installed)." |
| 294 | continue |
| 295 | fi |
| 296 | hr |
| 297 | log "[$software_choice] Running $name ($mode)..." |
| 298 | if ! run_script "$name" "$mode"; then |
| 299 | failures+=("$software_choice:$name") |
| 300 | fi |
| 301 | done |
| 302 | continue |
| 303 | fi |
| 304 | |
| 305 | spec="$(resolve_choice "$c")" |
| 306 | name="${spec%|*}" |
| 307 | mode="${spec##*|}" |
| 308 | hr |
| 309 | log "[$c] Running $name ($mode)..." |
| 310 | if ! run_script "$name" "$mode"; then |
| 311 | failures+=("$c:$name") |
| 312 | fi |
| 313 | done |
| 314 | |
| 315 | hr |
| 316 | if (( ${#failures[@]} == 0 )); then |
| 317 | log "All selected tasks completed." |
| 318 | else |
| 319 | warn "Completed with ${#failures[@]} failure(s): ${failures[*]}" |
| 320 | fi |
| 321 | echo |
| 322 | done |
| 323 |
| 1 | # Thunderbird backup settings. |
| 2 | # This file is fetched by backup-thunderbird-to-synology.sh from OpenGist. |
| 3 | # All paths should be absolute. |
| 4 | |
| 5 | SOURCE="/home/vernon/.thunderbird/" |
| 6 | DESTINATION="/mnt/Synology/Backup/Applications/Thunderbird/" |
| 7 | MOUNTPOINT="/mnt/Synology/Backup" |
| 8 | LOG_FILE="/home/vernon/.local/state/thunderbird-backup.log" |
| 9 | LOCK_FILE="/home/vernon/.cache/thunderbird-backup.lock" |
| 10 |
| 1 | # Thunderbird backup settings. |
| 2 | # Use this as a local override with: |
| 3 | # backup-thunderbird-to-synology.sh --config /path/to/thunderbird-backup.conf |
| 4 | # All paths should be absolute. |
| 5 | |
| 6 | SOURCE="/home/vernon/.thunderbird/" |
| 7 | DESTINATION="/mnt/Synology/Backup/Applications/Thunderbird/" |
| 8 | MOUNTPOINT="/mnt/Synology/Backup" |
| 9 | LOG_FILE="/home/vernon/.local/state/thunderbird-backup.log" |
| 10 | LOCK_FILE="/home/vernon/.cache/thunderbird-backup.lock" |
| 11 |
| 1 | #!/usr/bin/env bash |
| 2 | |
| 3 | set -euo pipefail |
| 4 | |
| 5 | echo "======================================" |
| 6 | echo " Ubuntu Dual-Boot Time Fix Script" |
| 7 | echo " (UTC Method - Highly Recommended)" |
| 8 | echo "======================================" |
| 9 | echo |
| 10 | |
| 11 | # Ensure running as root |
| 12 | if [[ $EUID -ne 0 ]]; then |
| 13 | echo "Please run as root:" |
| 14 | echo "sudo bash fix-time.sh" |
| 15 | exit 1 |
| 16 | fi |
| 17 | |
| 18 | # Detect current timezone instead of hardcoding |
| 19 | CURRENT_TZ=$(timedatectl show -p Timezone --value || echo "UTC") |
| 20 | |
| 21 | echo "[1/5] Ensuring timezone is set to $CURRENT_TZ..." |
| 22 | timedatectl set-timezone "$CURRENT_TZ" |
| 23 | |
| 24 | echo "[2/5] Configuring RTC to use UTC (to prevent dual-boot conflicts)..." |
| 25 | # '0' sets the hardware clock to UTC |
| 26 | timedatectl set-local-rtc 0 --adjust-system-clock |
| 27 | |
| 28 | echo "[3/5] Enabling NTP synchronization..." |
| 29 | timedatectl set-ntp true |
| 30 | |
| 31 | echo "[4/5] Restarting time sync daemon (if applicable)..." |
| 32 | systemctl restart systemd-timesyncd 2>/dev/null || true |
| 33 | |
| 34 | echo "[5/5] Final time configuration:" |
| 35 | echo |
| 36 | timedatectl |
| 37 | |
| 38 | echo |
| 39 | echo "======================================" |
| 40 | echo " SUCCESS - LINUX CONFIGURED" |
| 41 | echo "======================================" |
| 42 | echo |
| 43 | echo "Your system is now configured with:" |
| 44 | echo " - Timezone: $CURRENT_TZ" |
| 45 | echo " - RTC stored in UTC" |
| 46 | echo " - NTP synchronization enabled" |
| 47 | echo |
| 48 | |
| 49 | cat <<'EOF' |
| 50 | -------------------------------------------------- |
| 51 | WINDOWS CONFIGURATION (REQUIRED) |
| 52 | -------------------------------------------------- |
| 53 | Linux is now correctly expecting the hardware clock to be in UTC. |
| 54 | To make Windows do the same and stop the time from resetting, |
| 55 | you MUST run the following command in Windows. |
| 56 | |
| 57 | 1. Boot into Windows. |
| 58 | 2. Open Command Prompt as Administrator. |
| 59 | 3. Run this exact command: |
| 60 | |
| 61 | reg add "HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\TimeZoneInformation" /v RealTimeIsUniversal /d 1 /t REG_DWORD /f |
| 62 | |
| 63 | 4. Restart Windows and sync the time in Settings one last time. |
| 64 | EOF |