最終更新 2 weeks ago

Hardened interactive Ubuntu/Debian setup scripts, including CCUsage multi-device sync for Claude Code, Codex CLI, and OpenCode.

修正履歴 244ac64719b20113b9788672f54066cf78981144

README.md Raw

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
  • 0 runs setup options 1-23 in order
  • 26 updates installed software from options 1-7 and 9-20; software that is not installed is skipped
  • -1 exits

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 + ERR trap reporting the failing line number
  • /etc/os-release distro auto-detection (no hardcoded codenames)
  • dpkg --print-architecture / uname -m for 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 .desktop files
  • signed-by keyrings in /etc/apt/keyrings (no deprecated apt-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.sh takes a different code path since Debian has no PPAs)

Other distros are rejected up-front rather than failing later in unpredictable ways.

backup-thunderbird-to-synology.sh Raw
1#!/usr/bin/env bash
2set -euo pipefail
3
4CONFIG_URL_DEFAULT="https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD/thunderbird-backup.conf"
5CONFIG_FILE="${THUNDERBIRD_BACKUP_CONFIG:-}"
6CONFIG_URL="${THUNDERBIRD_BACKUP_CONFIG_URL:-$CONFIG_URL_DEFAULT}"
7SHOW_HELP=false
8
9args=("$@")
10for ((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
24done
25
26SOURCE_DEFAULT="/home/vernon/.thunderbird/"
27DESTINATION_DEFAULT="/mnt/Synology/Backup/Applications/Thunderbird/"
28MOUNTPOINT_DEFAULT="/mnt/Synology/Backup"
29LOG_FILE_DEFAULT="/home/vernon/.local/state/thunderbird-backup.log"
30LOCK_FILE_DEFAULT="/home/vernon/.cache/thunderbird-backup.lock"
31DRY_RUN=false
32
33SOURCE="$SOURCE_DEFAULT"
34DESTINATION="$DESTINATION_DEFAULT"
35MOUNTPOINT="$MOUNTPOINT_DEFAULT"
36LOG_FILE="$LOG_FILE_DEFAULT"
37LOCK_FILE="$LOCK_FILE_DEFAULT"
38
39trim() {
40 local value="$1"
41 value="${value#"${value%%[![:space:]]*}"}"
42 value="${value%"${value##*[![:space:]]}"}"
43 printf '%s' "$value"
44}
45
46parse_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
83usage() {
84 cat <<'USAGE'
85Usage: backup-thunderbird-to-synology.sh [options]
86
87Options:
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
98Config defaults to:
99 https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD/thunderbird-backup.conf
100
101Supported config variables: SOURCE, DESTINATION, MOUNTPOINT, LOG_FILE, LOCK_FILE.
102Environment overrides: THUNDERBIRD_BACKUP_SOURCE, THUNDERBIRD_BACKUP_DESTINATION,
103THUNDERBIRD_BACKUP_MOUNTPOINT, THUNDERBIRD_BACKUP_LOG_FILE, THUNDERBIRD_BACKUP_LOCK_FILE,
104THUNDERBIRD_BACKUP_CONFIG, THUNDERBIRD_BACKUP_CONFIG_URL.
105USAGE
106}
107
108if [[ "$SHOW_HELP" == true ]]; then
109 usage
110 exit 0
111fi
112
113if [[ -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"
120elif [[ -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"
127fi
128
129SOURCE="${THUNDERBIRD_BACKUP_SOURCE:-$SOURCE}"
130DESTINATION="${THUNDERBIRD_BACKUP_DESTINATION:-$DESTINATION}"
131MOUNTPOINT="${THUNDERBIRD_BACKUP_MOUNTPOINT:-$MOUNTPOINT}"
132LOG_FILE="${THUNDERBIRD_BACKUP_LOG_FILE:-$LOG_FILE}"
133LOCK_FILE="${THUNDERBIRD_BACKUP_LOCK_FILE:-$LOCK_FILE}"
134
135while [[ $# -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
177done
178
179mkdir -p "$(dirname "$LOG_FILE")" "$(dirname "$LOCK_FILE")"
180
181timestamp() {
182 date -Is
183}
184
185normalize_path() {
186 realpath -m "$1"
187}
188
189SOURCE_REAL="$(normalize_path "$SOURCE")"
190DESTINATION_REAL="$(normalize_path "$DESTINATION")"
191
192if [[ "$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
195fi
196
197if [[ -n "$MOUNTPOINT" && "$MOUNTPOINT" != /* ]]; then
198 echo "$(timestamp) ERROR: MOUNTPOINT must be an absolute path." >> "$LOG_FILE"
199 exit 1
200fi
201
202if [[ "$SOURCE_REAL" == "$DESTINATION_REAL" ]]; then
203 echo "$(timestamp) ERROR: SOURCE and DESTINATION cannot be the same path: $SOURCE_REAL" >> "$LOG_FILE"
204 exit 1
205fi
206
207if [[ "$DESTINATION_REAL" == "$SOURCE_REAL"/* ]]; then
208 echo "$(timestamp) ERROR: DESTINATION cannot be inside SOURCE: $DESTINATION_REAL" >> "$LOG_FILE"
209 exit 1
210fi
211
212case "$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 ;;
217esac
218
219if [[ -n "$MOUNTPOINT" ]] && ! mountpoint -q "$MOUNTPOINT"; then
220 echo "$(timestamp) ERROR: $MOUNTPOINT is not mounted; backup skipped." >> "$LOG_FILE"
221 exit 1
222fi
223
224if [[ ! -d "$SOURCE" ]]; then
225 echo "$(timestamp) ERROR: Thunderbird profile directory not found: $SOURCE" >> "$LOG_FILE"
226 exit 1
227fi
228
229mkdir -p "$DESTINATION"
230
231exec 9>"$LOCK_FILE"
232if ! flock -n 9; then
233 echo "$(timestamp) INFO: Previous Thunderbird backup still running; backup skipped." >> "$LOG_FILE"
234 exit 0
235fi
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
disable-grub-countdown.sh Raw
1#!/usr/bin/env bash
2# disable-grub-countdown.sh — Show the GRUB dual-boot menu without a countdown.
3
4set -euo pipefail
5IFS=$'\n\t'
6
7readonly SCRIPT_NAME="${0##*/}"
8readonly GRUB_DEFAULTS="/etc/default/grub"
9DRY_RUN=0
10
11usage() {
12 cat <<EOF
13Usage: $SCRIPT_NAME [--dry-run] [--help]
14
15Configures GRUB to show the boot menu and wait indefinitely:
16
17 GRUB_TIMEOUT_STYLE=menu
18 GRUB_TIMEOUT=-1
19
20Options:
21 --dry-run Print actions without executing.
22 --help, -h Show this help.
23EOF
24}
25
26log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
27die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
28
29while (( $# )); 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
36done
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
41for tool in cp date grep mktemp sed; do
42 command -v "$tool" >/dev/null 2>&1 || die "Missing required tool: $tool"
43done
44
45if command -v update-grub >/dev/null 2>&1; then
46 GRUB_UPDATE_CMD=(update-grub)
47elif command -v grub-mkconfig >/dev/null 2>&1; then
48 GRUB_UPDATE_CMD=(grub-mkconfig -o /boot/grub/grub.cfg)
49elif 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
55else
56 die "Could not find update-grub, grub-mkconfig, or grub2-mkconfig."
57fi
58
59BACKUP="${GRUB_DEFAULTS}.backup.$(date +%Y%m%d-%H%M%S)"
60STAGE="$(mktemp -t grub-defaults.XXXXXX)"
61trap 'rm -f "$STAGE"' EXIT
62
63cp "$GRUB_DEFAULTS" "$STAGE"
64
65if grep -q '^GRUB_TIMEOUT_STYLE=' "$STAGE"; then
66 sed -i 's/^GRUB_TIMEOUT_STYLE=.*/GRUB_TIMEOUT_STYLE=menu/' "$STAGE"
67else
68 printf '\nGRUB_TIMEOUT_STYLE=menu\n' >>"$STAGE"
69fi
70
71if grep -q '^GRUB_TIMEOUT=' "$STAGE"; then
72 sed -i 's/^GRUB_TIMEOUT=.*/GRUB_TIMEOUT=-1/' "$STAGE"
73else
74 printf 'GRUB_TIMEOUT=-1\n' >>"$STAGE"
75fi
76
77if (( 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
83fi
84
85cp "$GRUB_DEFAULTS" "$BACKUP"
86cp "$STAGE" "$GRUB_DEFAULTS"
87
88log "Updated $GRUB_DEFAULTS"
89log "Backup saved to $BACKUP"
90log "Regenerating GRUB configuration..."
91"${GRUB_UPDATE_CMD[@]}"
92
93log "Done. On next boot, the GRUB menu will wait until you choose an entry."
94
install-1password.sh Raw
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
5set -euo pipefail
6IFS=$'\n\t'
7
8readonly SCRIPT_NAME="${0##*/}"
9DRY_RUN=0
10
11usage() {
12 cat <<EOF
13Usage: sudo $SCRIPT_NAME [--dry-run] [--help]
14
15Configures the official 1Password APT repository (with debsig policy and
16keyring) and installs the 1Password desktop application. Idempotent: safe to
17re-run.
18
19Options:
20 --dry-run Print actions without executing.
21 --help, -h Show this help.
22EOF
23}
24
25log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
26warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; }
27die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
28run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; }
29trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR
30
31while (( $# )); 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
38done
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
45case "${ID:-}:${ID_LIKE:-}" in
46 *ubuntu*|*debian*) : ;;
47 *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;;
48esac
49
50# 1Password publishes builds for amd64 and arm64.
51ARCH="$(dpkg --print-architecture)"
52case "$ARCH" in
53 amd64|arm64) : ;;
54 *) die "1Password is only available for amd64/arm64 (detected: $ARCH)." ;;
55esac
56log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH"
57
58export DEBIAN_FRONTEND=noninteractive
59
60KEYRING=/etc/apt/keyrings/1password-archive-keyring.gpg
61SOURCES=/etc/apt/sources.list.d/1password.list
62DEBSIG_POLICY_DIR=/etc/debsig/policies/AC2D62742012EA22
63DEBSIG_KEYRING_DIR=/usr/share/debsig/keyrings/AC2D62742012EA22
64
65log "Installing prerequisites..."
66run "apt-get update -qq"
67run "apt-get install -y curl gpg ca-certificates"
68
69log "Configuring 1Password APT repository..."
70run "install -d -m 0755 /etc/apt/keyrings"
71if [[ ! -s "$KEYRING" ]]; then
72 run "curl -fsSL https://downloads.1password.com/linux/keys/1password.asc | gpg --dearmor -o '$KEYRING'"
73 run "chmod 0644 '$KEYRING'"
74fi
75
76DESIRED_SRC="deb [arch=${ARCH} signed-by=${KEYRING}] https://downloads.1password.com/linux/debian/${ARCH} stable main"
77if [[ ! -f "$SOURCES" ]] || ! grep -qxF "$DESIRED_SRC" "$SOURCES"; then
78 run "printf '%s\n' '$DESIRED_SRC' > '$SOURCES'"
79fi
80
81log "Installing debsig policy (verifies package signatures on install)..."
82run "install -d -m 0755 '$DEBSIG_POLICY_DIR' '$DEBSIG_KEYRING_DIR'"
83if [[ ! -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'"
85fi
86if [[ ! -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'"
88fi
89
90log "Installing 1Password..."
91run "apt-get update -qq"
92run "apt-get install -y 1password"
93
94if (( ! DRY_RUN )) && command -v 1password >/dev/null 2>&1; then
95 log "1Password binary available at: $(command -v 1password)"
96fi
97log "Done."
98
install-bruno.sh Raw
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
6set -euo pipefail
7IFS=$'\n\t'
8
9readonly SCRIPT_NAME="${0##*/}"
10DRY_RUN=0
11
12usage() {
13 cat <<EOF
14Usage: sudo $SCRIPT_NAME [--dry-run] [--help]
15
16Configures the official Bruno APT repository (signed-by keyring fetched from
17keyserver.ubuntu.com with retries) and installs Bruno. Idempotent: safe to
18re-run.
19
20Options:
21 --dry-run Print actions without executing.
22 --help, -h Show this help.
23EOF
24}
25
26log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
27warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; }
28die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
29run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; }
30trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR
31
32while (( $# )); 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
39done
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
46case "${ID:-}:${ID_LIKE:-}" in
47 *ubuntu*|*debian*) : ;;
48 *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;;
49esac
50
51ARCH="$(dpkg --print-architecture)"
52[[ "$ARCH" == "amd64" ]] || die "Bruno APT repo only ships amd64 (detected: $ARCH)."
53log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH"
54
55export DEBIAN_FRONTEND=noninteractive
56
57KEYRING=/etc/apt/keyrings/bruno.gpg
58SOURCES=/etc/apt/sources.list.d/bruno.list
59KEY_ID="9FA6017ECABE0266"
60DESKTOP=/usr/share/applications/bruno.desktop
61
62log "Installing prerequisites..."
63run "apt-get update -qq"
64run "apt-get install -y curl gpg ca-certificates"
65
66log "Configuring Bruno APT repository..."
67run "install -d -m 0755 /etc/apt/keyrings"
68
69if [[ ! -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'"
88fi
89
90DESIRED_SRC="deb [arch=${ARCH} signed-by=${KEYRING}] http://debian.usebruno.com/ bruno stable"
91if [[ ! -f "$SOURCES" ]] || ! grep -qxF "$DESIRED_SRC" "$SOURCES"; then
92 run "printf '%s\n' '$DESIRED_SRC' > '$SOURCES'"
93fi
94
95log "Installing Bruno..."
96run "apt-get update -qq"
97run "apt-get install -y bruno"
98
99log "Writing desktop entry..."
100if (( DRY_RUN )); then
101 printf ' DRY-RUN: write %s\n' "$DESKTOP"
102else
103 cat >"$DESKTOP" <<'EOF'
104[Desktop Entry]
105Name=Bruno
106Comment=Open-source API Client
107Exec=bruno %U
108Terminal=false
109Type=Application
110Icon=bruno
111Categories=Development;Utility;
112StartupNotify=true
113EOF
114 chmod 0644 "$DESKTOP"
115fi
116
117log "Done. Bruno installed."
118
install-chrome.sh Raw
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
9set -euo pipefail
10IFS=$'\n\t'
11
12# Ensure the script is run as root
13if (( EUID != 0 )); then
14 echo "[ERROR] This script must be run as root (via sudo)." >&2
15 exit 1
16fi
17
18echo "[1/4] Installing necessary prerequisites..."
19apt-get update -y
20apt-get install -y curl gnupg2 ca-certificates
21
22echo "[2/4] Setting up Google's official repository keys..."
23# Create directory for Third-Party APT keys if it doesn't exist
24install -d -m 0755 /etc/apt/keyrings
25
26# Fetch the official signing key and save it securely
27curl -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
31echo "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
34echo "[3/4] Fetching package lists and installing Google Chrome..."
35apt-get update -y
36apt-get install -y google-chrome-stable
37
38echo "[4/4] Verifying installation..."
39if 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 "------------------------------------------------------"
44else
45 echo "[ERROR] Google Chrome installation could not be verified." >&2
46 exit 1
47fi
install-discord.sh Raw
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
5set -euo pipefail
6IFS=$'\n\t'
7
8readonly SCRIPT_NAME="${0##*/}"
9DRY_RUN=0
10DOWNLOAD_URL="https://discord.com/api/download?platform=linux&format=deb"
11
12usage() {
13 cat <<EOF
14Usage: sudo $SCRIPT_NAME [--dry-run] [--help]
15
16Downloads Discord from Discord's official latest Linux .deb endpoint and
17installs it with apt-get.
18
19Options:
20 --dry-run Print actions without executing.
21 --help, -h Show this help.
22EOF
23}
24
25log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
26die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
27run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; }
28trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR
29
30while (( $# )); 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
37done
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
44case "${ID:-}:${ID_LIKE:-}" in
45 *ubuntu*|*debian*) : ;;
46 *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;;
47esac
48
49ARCH="$(dpkg --print-architecture)"
50[[ "$ARCH" == "amd64" ]] || die "Discord's official Linux .deb targets amd64 only (detected: $ARCH)."
51log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH"
52
53export DEBIAN_FRONTEND=noninteractive
54log "Installing prerequisites..."
55run "apt-get update -qq"
56run "apt-get install -y curl ca-certificates"
57
58STAGE="$(mktemp -d -t discord.XXXXXX)"
59trap 'rm -rf "$STAGE"' EXIT
60DEB="$STAGE/discord.deb"
61
62log "Downloading latest Discord .deb..."
63run "curl -fL -o '$DEB' '$DOWNLOAD_URL'"
64
65if (( DRY_RUN )); then
66 printf ' DRY-RUN: apt-get install -y %s\n' "$DEB"
67 log "Done."
68 exit 0
69fi
70
71log "Installing Discord..."
72run "apt-get install -y '$DEB'"
73
74if 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)"
76else
77 log "Installed: $(dpkg-query -W -f='${Version}' discord 2>/dev/null || echo Discord)"
78fi
79log "Done."
80
install-drawio.sh Raw
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
6set -euo pipefail
7IFS=$'\n\t'
8
9readonly SCRIPT_NAME="${0##*/}"
10DRY_RUN=0
11REPO="jgraph/drawio-desktop"
12
13usage() {
14 cat <<EOF
15Usage: sudo $SCRIPT_NAME [--dry-run] [--help]
16
17Resolves the latest release of jgraph/drawio-desktop, downloads the official
18Linux .deb package for your architecture, verifies its SHA-256 against the
19GitHub release asset digest when available, and installs it with apt-get.
20
21If \$GITHUB_TOKEN is set in the environment, it is used to authenticate the
22GitHub API request (avoids rate limits).
23
24Options:
25 --dry-run Print actions without executing.
26 --help, -h Show this help.
27EOF
28}
29
30log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
31warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; }
32die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
33run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; }
34trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR
35
36while (( $# )); 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
43done
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
50case "${ID:-}:${ID_LIKE:-}" in
51 *ubuntu*|*debian*) : ;;
52 *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;;
53esac
54
55ARCH="$(dpkg --print-architecture)"
56case "$ARCH" in
57 amd64|arm64) : ;;
58 *) die "draw.io Desktop publishes Linux .deb assets for amd64 and arm64 only (detected: $ARCH)." ;;
59esac
60log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH"
61
62export DEBIAN_FRONTEND=noninteractive
63log "Installing prerequisites..."
64run "apt-get update -qq"
65run "apt-get install -y curl jq ca-certificates"
66
67GH_HDRS=(-H "Accept: application/vnd.github+json")
68[[ -n "${GITHUB_TOKEN:-}" ]] && GH_HDRS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
69
70log "Querying GitHub API for latest release of $REPO..."
71RELEASE_JSON="$(curl -fsSL "${GH_HDRS[@]}" "https://api.github.com/repos/${REPO}/releases/latest")"
72TAG="$(jq -r '.tag_name // empty' <<<"$RELEASE_JSON")"
73[[ -n "$TAG" ]] || die "Could not parse latest release tag (rate limited? set GITHUB_TOKEN)."
74log "Latest release: $TAG"
75
76ASSET_REGEX="^drawio-${ARCH}-[^/]+\\.deb$"
77DOWNLOAD_URL="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | .browser_download_url' <<<"$RELEASE_JSON" | head -n1)"
78ASSET_NAME="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | .name' <<<"$RELEASE_JSON" | head -n1)"
79EXPECTED_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
82STAGE="$(mktemp -d -t drawio.XXXXXX)"
83trap 'rm -rf "$STAGE"' EXIT
84DEB="$STAGE/$ASSET_NAME"
85
86log "Downloading $ASSET_NAME..."
87run "curl -fsSL -o '$DEB' '$DOWNLOAD_URL'"
88
89if (( 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
96fi
97
98if [[ -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."
103else
104 warn "No SHA-256 asset digest in GitHub release metadata; skipping verification."
105fi
106
107log "Installing draw.io Desktop..."
108run "apt-get install -y '$DEB'"
109
110if (( ! DRY_RUN )) && command -v drawio >/dev/null 2>&1; then
111 log "Installed: $(drawio --version 2>/dev/null || echo "$TAG")"
112fi
113log "Done."
114
install-espanso.sh Raw
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
6set -euo pipefail
7IFS=$'\n\t'
8
9readonly SCRIPT_NAME="${0##*/}"
10DRY_RUN=0
11FORCE_VARIANT="" # "wayland" | "x11"
12
13usage() {
14 cat <<EOF
15Usage: sudo $SCRIPT_NAME [--dry-run] [--wayland|--x11] [--help]
16
17Installs Espanso from the latest GitHub release. Auto-detects whether to use
18the Wayland or X11 build based on the invoking user (SUDO_USER).
19The systemd-user service is registered for that user, not root.
20
21Options:
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.
26EOF
27}
28
29log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
30warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; }
31die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
32run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; }
33trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR
34
35while (( $# )); 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
44done
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
51case "${ID:-}:${ID_LIKE:-}" in
52 *ubuntu*|*debian*) : ;;
53 *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;;
54esac
55
56ARCH="$(dpkg --print-architecture)"
57[[ "$ARCH" == "amd64" ]] || die "Espanso .deb is published for amd64 only (detected: $ARCH)."
58
59ACTUAL_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."
61USER_ID="$(id -u "$ACTUAL_USER")"
62
63# --- FIXED: Better session detection logic using loginctl ---
64if [[ -n "$FORCE_VARIANT" ]]; then
65 VARIANT="$FORCE_VARIANT"
66else
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
91fi
92log "Detected: ${PRETTY_NAME:-unknown}, user: $ACTUAL_USER, session: $VARIANT"
93
94export DEBIAN_FRONTEND=noninteractive
95
96# --- FIXED: Use a subdirectory to avoid apt "unsandboxed" permission warnings ---
97WORK_DIR="$(mktemp -d /tmp/espanso-install.XXXXXX)"
98chmod 755 "$WORK_DIR"
99TEMP_DEB="$WORK_DIR/espanso.deb"
100trap 'rm -rf "$WORK_DIR"' EXIT
101
102DEB_NAME="espanso-debian-${VARIANT}-amd64.deb"
103URL="https://github.com/espanso/espanso/releases/latest/download/${DEB_NAME}"
104
105log "Installing prerequisites..."
106run "apt-get update -qq"
107run "apt-get install -y wget libcap2-bin psmisc"
108
109# --- FIXED: Use wget with progress bar instead of silent mode ---
110log "Downloading ${DEB_NAME}..."
111run "wget -q --show-progress -O '$TEMP_DEB' '$URL'"
112
113log "Removing conflicting Espanso packages (if any)..."
114run "apt-get remove -y espanso espanso-wayland >/dev/null 2>&1 || true"
115
116log "Installing package..."
117run "apt-get install -y '$TEMP_DEB'"
118
119ESPANSO_BIN="$(command -v espanso || true)"
120[[ -x "$ESPANSO_BIN" ]] || die "espanso binary not found after install."
121
122if [[ "$VARIANT" == "wayland" ]]; then
123 log "Setting CAP_DAC_OVERRIDE on $ESPANSO_BIN..."
124 run "setcap 'cap_dac_override+p' '$ESPANSO_BIN'"
125fi
126
127log "Clearing ghost processes to prevent start timeouts..."
128run "sudo -u '$ACTUAL_USER' killall espanso 2>/dev/null || true"
129
130log "Registering & starting espanso service for $ACTUAL_USER..."
131if (( 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"
134else
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
138fi
139
140log "Done. Espanso ($VARIANT) installed and started for $ACTUAL_USER."
141if [[ "$VARIANT" == "wayland" ]]; then
142 log "Wayland note: non-US keyboards must set the layout in ~/.config/espanso/config/default.yml"
143fi
install-firefox.sh Raw
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
7set -euo pipefail
8IFS=$'\n\t'
9
10readonly SCRIPT_NAME="${0##*/}"
11DRY_RUN=0
12ASSUME_YES=0
13
14usage() {
15 cat <<EOF
16Usage: sudo $SCRIPT_NAME [--dry-run] [--yes] [--help]
17
18Removes Firefox Snap (and Ubuntu's transitional wrapper), configures the
19official Mozilla APT repository (signed-by keyring + APT pin), then installs
20Firefox so it auto-updates from Mozilla. On GNOME, Firefox is pinned at Dock
21position 2 when a desktop user session is available.
22
23Options:
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.
27EOF
28}
29
30log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
31warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; }
32die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
33run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; }
34
35on_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; }
36trap 'on_err $LINENO' ERR
37
38while (( $# )); 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
46done
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
53case "${ID:-}:${ID_LIKE:-}" in
54 *ubuntu*|*debian*) : ;;
55 *) die "Unsupported distro: ${PRETTY_NAME:-unknown}. Requires Debian/Ubuntu." ;;
56esac
57log "Detected: ${PRETTY_NAME:-unknown} (codename: ${VERSION_CODENAME:-?})"
58
59APT_YES=()
60(( ASSUME_YES )) && APT_YES=(-y) || APT_YES=(-y) # always -y for safety in scripted use
61export DEBIAN_FRONTEND=noninteractive
62
63KEYRING=/etc/apt/keyrings/packages.mozilla.org.asc
64SOURCES=/etc/apt/sources.list.d/mozilla.list
65PIN=/etc/apt/preferences.d/mozilla
66
67log "Removing Firefox Snap and Ubuntu's transitional wrapper (if present)..."
68if 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"
71fi
72run "apt-get remove --purge ${APT_YES[*]} firefox >/dev/null 2>&1 || true"
73run "rm -f /usr/bin/firefox"
74
75log "Installing prerequisites (wget, gpg, ca-certificates)..."
76run "apt-get update -qq"
77run "apt-get install ${APT_YES[*]} wget gpg ca-certificates"
78
79log "Configuring Mozilla APT repository..."
80run "install -d -m 0755 /etc/apt/keyrings"
81if [[ ! -s "$KEYRING" ]]; then
82 run "wget -qO '$KEYRING' https://packages.mozilla.org/apt/repo-signing-key.gpg"
83 run "chmod 0644 '$KEYRING'"
84else
85 log "Keyring already present at $KEYRING (skipping download)."
86fi
87
88# Verify key fingerprint matches Mozilla's published fingerprint
89EXPECTED_FPR="35BAA0B33E9EB396F59CA838C0BA5CE6DC6315A3"
90ACTUAL_FPR="$(gpg --show-keys --with-colons "$KEYRING" 2>/dev/null | awk -F: '/^fpr/ {print $10; exit}')"
91if [[ "$ACTUAL_FPR" != "$EXPECTED_FPR" ]]; then
92 warn "Mozilla key fingerprint mismatch (expected $EXPECTED_FPR, got ${ACTUAL_FPR:-none}). Continuing, but verify manually."
93else
94 log "Mozilla key fingerprint verified."
95fi
96
97DESIRED_SRC='deb [signed-by=/etc/apt/keyrings/packages.mozilla.org.asc] https://packages.mozilla.org/apt mozilla main'
98if [[ ! -f "$SOURCES" ]] || ! grep -qxF "$DESIRED_SRC" "$SOURCES"; then
99 run "printf '%s\n' '$DESIRED_SRC' > '$SOURCES'"
100fi
101
102log "Pinning Mozilla repo to priority 1000..."
103if [[ ! -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'
108Package: *
109Pin: origin packages.mozilla.org
110Pin-Priority: 1000
111EOF
112 fi
113fi
114
115log "Installing Firefox from Mozilla repo..."
116run "apt-get update -qq"
117run "apt-get install ${APT_YES[*]} firefox"
118
119if (( ! DRY_RUN )) && command -v firefox >/dev/null 2>&1; then
120 log "Installed: $(firefox --version 2>/dev/null || echo 'firefox')"
121fi
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.
126DESKTOP_USER="${SUDO_USER:-}"
127if (( DRY_RUN )); then
128 printf ' DRY-RUN: pin firefox.desktop at GNOME Dock position 2 when a desktop session is available\n'
129elif [[ -z "$DESKTOP_USER" || "$DESKTOP_USER" == "root" ]]; then
130 warn "No sudo desktop user detected; skipping GNOME Dock pin."
131else
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
176fi
177
178log "Done. Firefox installed from Mozilla APT and will auto-update."
179
install-font.sh Raw
1#!/usr/bin/env bash
2
3set -euo pipefail
4IFS=$'\n\t'
5
6readonly SCRIPT_NAME="${0##*/}"
7readonly FONT_BASE_DIR="$HOME/.local/share/fonts"
8readonly TMP_DIR="$(mktemp -d)"
9
10log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
11warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; }
12die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
13
14cleanup() {
15 rm -rf "$TMP_DIR"
16}
17trap cleanup EXIT
18
19if (( EUID == 0 )); then
20 die "Do not run as root. Run as your normal desktop user so fonts install under $HOME."
21fi
22
23for tool in curl unzip fc-cache find; do
24 command -v "$tool" >/dev/null 2>&1 || die "Missing required tool: $tool"
25done
26
27latest_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
43install_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
76mkdir -p "$FONT_BASE_DIR"
77
78install_nerd_font \
79 "Ubuntu Sans Nerd Font" \
80 "UbuntuSans" \
81 "$FONT_BASE_DIR/UbuntuSans" \
82 ""
83
84install_nerd_font \
85 "JetBrains Mono Nerd Font" \
86 "JetBrainsMono" \
87 "$FONT_BASE_DIR/JetBrainsMonoNerdFont" \
88 ""
89
90log "Updating font cache..."
91fc-cache -f "$FONT_BASE_DIR"
92
93if command -v fc-match >/dev/null 2>&1; then
94 log "Resolved terminal font: $(fc-match 'JetBrainsMono Nerd Font Mono')"
95fi
96
97log "Done. Configure your terminal to use JetBrainsMono Nerd Font Mono for Starship Powerline symbols."
98
install-ibus-pinyin.sh Raw
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
9set -euo pipefail
10IFS=$'\n\t'
11
12readonly SCRIPT_NAME="${0##*/}"
13DRY_RUN=0
14SKIP_GSETTINGS=0
15
16usage() {
17 cat <<EOF
18Usage: $SCRIPT_NAME [--dry-run] [--skip-gsettings] [--help]
19
20Installs ibus-libpinyin (plus Chinese language packs), refreshes IBus, and
21registers 'Intelligent Pinyin' as a GNOME input source. Idempotent.
22
23Do NOT run with sudo: gsettings is per-user and must run as the desktop user.
24The script invokes sudo internally only for apt-get steps.
25
26Options:
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
32Switch input methods at runtime with: Super + Space
33EOF
34}
35
36log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
37warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; }
38die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
39trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR
40
41while (( $# )); 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
49done
50
51# Resolve the desktop user. If invoked via sudo, gsettings must target SUDO_USER.
52if (( 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."
56else
57 DESKTOP_USER="$USER"
58fi
59
60# Look up the user's UID for DBus access when invoked through sudo.
61USER_ENTRY="$(getent passwd "$DESKTOP_USER")" || die "User '$DESKTOP_USER' not found in passwd."
62USER_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
67case "${ID:-}:${ID_LIKE:-}" in
68 *ubuntu*|*debian*) : ;;
69 *) die "Unsupported distro: ${PRETTY_NAME:-unknown}. Requires Debian/Ubuntu." ;;
70esac
71log "Detected: ${PRETTY_NAME:-unknown}, desktop user: $DESKTOP_USER"
72
73# Helpers: invoke a command as $DESKTOP_USER with a working DBus address.
74query_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
85run_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.
96sudo_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
106export DEBIAN_FRONTEND=noninteractive
107
108log "Installing IBus Intelligent Pinyin + Chinese language packs..."
109sudo_run apt-get update -qq
110sudo_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.
119if 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."
124elif 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."
128fi
129
130if (( 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
134fi
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.
139if ! 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
143fi
144
145log "Adding 'Intelligent Pinyin' to GNOME Input Sources (idempotent)..."
146
147CURRENT_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.
149CLEAN_SOURCES="${CURRENT_SOURCES#@a(ss) }"
150CLEAN_SOURCES="${CLEAN_SOURCES#@as }"
151
152if [[ "$CLEAN_SOURCES" == *"'ibus', 'libpinyin'"* ]]; then
153 log "Intelligent Pinyin already present in input sources — nothing to do."
154else
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')"
166fi
167
168log "---"
169log "Done! Intelligent Pinyin is installed and configured."
170log "Switch input methods with: Super + Space"
171log "If 'Chinese (Intelligent Pinyin)' doesn't appear in the top bar right away, log out and back in."
172
install-ipatool.sh Raw
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
6set -euo pipefail
7IFS=$'\n\t'
8
9readonly SCRIPT_NAME="${0##*/}"
10DRY_RUN=0
11REPO="majd/ipatool"
12INSTALL_PATH="/usr/local/bin/ipatool"
13
14usage() {
15 cat <<EOF
16Usage: sudo $SCRIPT_NAME [--dry-run] [--help]
17
18Resolves the latest release of majd/ipatool, downloads the tarball for your
19architecture, verifies its SHA-256 against the published checksums.txt, and
20installs the binary to /usr/local/bin/ipatool atomically.
21
22If \$GITHUB_TOKEN is set in the environment, it is used to authenticate the
23GitHub API request (avoids rate limits).
24
25Options:
26 --dry-run Print actions without executing.
27 --help, -h Show this help.
28EOF
29}
30
31log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
32warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; }
33die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
34run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; }
35trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR
36
37while (( $# )); 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
44done
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
51log "Detected: ${PRETTY_NAME:-unknown}"
52
53ARCH_RAW="$(uname -m)"
54case "$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" ;;
59esac
60ASSET_SUFFIX="linux-${GO_ARCH}.tar.gz"
61
62export DEBIAN_FRONTEND=noninteractive
63log "Installing prerequisites..."
64run "apt-get update -qq"
65run "apt-get install -y curl jq tar ca-certificates libsecret-1-0"
66
67GH_HDRS=(-H "Accept: application/vnd.github+json")
68[[ -n "${GITHUB_TOKEN:-}" ]] && GH_HDRS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
69
70log "Querying GitHub API for latest release of $REPO..."
71RELEASE_JSON="$(curl -fsSL "${GH_HDRS[@]}" "https://api.github.com/repos/${REPO}/releases/latest")"
72TAG="$(jq -r '.tag_name // empty' <<<"$RELEASE_JSON")"
73[[ -n "$TAG" ]] || die "Could not parse latest release tag (rate limited? set GITHUB_TOKEN)."
74log "Latest release: $TAG"
75
76DOWNLOAD_URL="$(jq -r --arg s "$ASSET_SUFFIX" '.assets[] | select(.name | endswith($s)) | .browser_download_url' <<<"$RELEASE_JSON" | head -n1)"
77CHECKSUM_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
80STAGE="$(mktemp -d -t ipatool.XXXXXX)"
81trap 'rm -rf "$STAGE"' EXIT
82TARBALL="$STAGE/ipatool.tar.gz"
83
84log "Downloading $(basename "$DOWNLOAD_URL")..."
85run "curl -fsSL -o '$TARBALL' '$DOWNLOAD_URL'"
86
87if [[ -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."
95else
96 warn "No checksums.txt in release; skipping SHA-256 verification."
97fi
98
99log "Extracting..."
100run "tar -xzf '$TARBALL' -C '$STAGE'"
101BINARY_PATH="$(find "$STAGE" -type f -name ipatool -executable -not -name '*.tar.gz' | head -n1 || true)"
102if [[ -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)"
105fi
106[[ -n "$BINARY_PATH" || $DRY_RUN -eq 1 ]] || die "ipatool binary not found inside archive."
107
108log "Installing to $INSTALL_PATH..."
109run "install -m 0755 '$BINARY_PATH' '$INSTALL_PATH'"
110
111if (( ! DRY_RUN )) && command -v ipatool >/dev/null 2>&1; then
112 log "Installed: $(ipatool --version 2>/dev/null || basename "$INSTALL_PATH") ($TAG)"
113fi
114log "Done."
115
install-jetbrains-toolbox.sh Raw
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
6set -euo pipefail
7IFS=$'\n\t'
8
9readonly SCRIPT_NAME="${0##*/}"
10DRY_RUN=0
11ASSUME_YES=0
12
13usage() {
14 cat <<EOF
15Usage: $SCRIPT_NAME [--dry-run] [--yes] [--help]
16
17Installs the latest JetBrains Toolbox to ~/.local/share/JetBrains/Toolbox and
18creates a .desktop launcher in ~/.local/share/applications. The .tar.gz is
19verified against the SHA-256 published in JetBrains' release feed.
20
21Do NOT run with sudo: Toolbox is a per-user install. If a missing system
22package (curl/jq/tar/libfuse2) needs installing, the script will call sudo
23just for that step.
24
25Options:
26 --yes, -y Auto-confirm prompts for installing missing system packages.
27 --dry-run Print actions without executing.
28 --help, -h Show this help.
29EOF
30}
31
32log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
33warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; }
34die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
35run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; }
36trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR
37
38while (( $# )); 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
46done
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
53case "${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}." ;;
58esac
59log "Detected: ${PRETTY_NAME:-unknown} (pkg: $PKG_MGR)"
60
61confirm() {
62 (( ASSUME_YES )) && return 0
63 read -rp "$1 [y/N] " ans
64 [[ "$ans" =~ ^[Yy] ]]
65}
66
67ensure_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
81ensure_pkg curl "command -v curl"
82ensure_pkg jq "command -v jq"
83ensure_pkg tar "command -v tar"
84case "$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" ;;
88esac
89
90ARCH_RAW="$(uname -m)"
91case "$ARCH_RAW" in
92 x86_64) TOOLBOX_ARCH_KEY=linux ;;
93 aarch64) TOOLBOX_ARCH_KEY=linuxARM64 ;;
94 *) die "Unsupported architecture: $ARCH_RAW" ;;
95esac
96
97log "Querying JetBrains release feed..."
98RELEASE_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
102TOOLBOX_URL="$(jq -r --arg k "$TOOLBOX_ARCH_KEY" '.TBA[0].downloads[$k].link // empty' <<<"$RELEASE_JSON")"
103TOOLBOX_SHA="$(jq -r --arg k "$TOOLBOX_ARCH_KEY" '.TBA[0].downloads[$k].checksumLink // empty' <<<"$RELEASE_JSON")"
104TOOLBOX_VER="$(jq -r '.TBA[0].version // "?"' <<<"$RELEASE_JSON")"
105[[ -n "$TOOLBOX_URL" ]] || die "Could not resolve Toolbox download URL from release feed."
106log "Latest Toolbox: $TOOLBOX_VER ($TOOLBOX_ARCH_KEY)"
107
108INSTALL_DIR="$HOME/.local/share/JetBrains/Toolbox"
109STAGE_DIR="$(mktemp -d -t toolbox.XXXXXX)"
110trap 'rm -rf "$STAGE_DIR"' EXIT
111
112TARBALL="$STAGE_DIR/toolbox.tar.gz"
113log "Downloading..."
114run "curl -fsSL -o '$TARBALL' '$TOOLBOX_URL'"
115
116if [[ -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."
124else
125 warn "No checksum URL in release feed; skipping verification."
126fi
127
128log "Extracting to $INSTALL_DIR..."
129run "mkdir -p '$INSTALL_DIR'"
130run "tar -xzf '$TARBALL' --strip-components=1 -C '$INSTALL_DIR'"
131
132BIN_PATH="$INSTALL_DIR/bin/jetbrains-toolbox"
133DESKTOP_SRC="$INSTALL_DIR/bin/jetbrains-toolbox.desktop"
134ICON_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
139if [[ -d "$HOME/bin" ]]; then
140 run "ln -sfn '$BIN_PATH' '$HOME/bin/jetbrains-toolbox'"
141fi
142
143# .desktop launcher
144APPS_DIR="$HOME/.local/share/applications"
145run "mkdir -p '$APPS_DIR'"
146DESKTOP_DST="$APPS_DIR/jetbrains-toolbox.desktop"
147if [[ -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"
155else
156 warn "No .desktop file in archive; skipping launcher."
157fi
158
159log "Done. JetBrains Toolbox $TOOLBOX_VER installed to $INSTALL_DIR"
160
install-libreoffice.sh Raw
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
6set -euo pipefail
7IFS=$'\n\t'
8
9readonly SCRIPT_NAME="${0##*/}"
10DRY_RUN=0
11SKIP_REMOVE_DISTRO=0
12
13usage() {
14 cat <<EOF
15Usage: sudo $SCRIPT_NAME [--dry-run] [--keep-distro-libreoffice] [--help]
16
17Detects the latest stable LibreOffice version on download.documentfoundation.org,
18downloads the matching DEB tarball for your architecture, verifies the MD5 sum
19published alongside it, and installs the .deb packages.
20
21Options:
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.
26EOF
27}
28
29log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
30warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; }
31die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
32run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; }
33trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR
34
35while (( $# )); 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
43done
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
50case "${ID:-}:${ID_LIKE:-}" in
51 *ubuntu*|*debian*) : ;;
52 *) die "Unsupported distro: ${PRETTY_NAME:-unknown} (this script installs .deb packages)." ;;
53esac
54
55ARCH_RAW="$(uname -m)"
56case "$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)." ;;
60esac
61log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH_RAW"
62
63export DEBIAN_FRONTEND=noninteractive
64
65log "Installing prerequisites..."
66run "apt-get update -qq"
67run "apt-get install -y curl wget tar coreutils ca-certificates"
68
69log "Resolving latest stable LibreOffice version..."
70INDEX="$(curl -fsSL https://download.documentfoundation.org/libreoffice/stable/)"
71LATEST_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."
75log "Latest stable: $LATEST_VERSION"
76
77BASE="https://download.documentfoundation.org/libreoffice/stable/${LATEST_VERSION}/deb/${LO_ARCH}"
78ARCHIVE_NAME="LibreOffice_${LATEST_VERSION}_${LO_SUFFIX}_deb.tar.gz"
79URL="${BASE}/${ARCHIVE_NAME}"
80SUM_URL="${URL}.md5"
81
82STAGE="$(mktemp -d -t lo.XXXXXX)"
83trap 'rm -rf "$STAGE"' EXIT
84ARCHIVE="$STAGE/$ARCHIVE_NAME"
85
86log "Downloading $ARCHIVE_NAME..."
87run "wget -q --show-progress -O '$ARCHIVE' '$URL'"
88
89log "Verifying MD5..."
90if 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."
94else
95 warn "No MD5 published for $SUM_URL; skipping checksum."
96fi
97
98log "Extracting..."
99run "tar -xzf '$ARCHIVE' -C '$STAGE'"
100DEBS_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
103if (( ! 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
108fi
109
110log "Installing .deb packages..."
111run "dpkg -i -R '$DEBS_DIR'"
112log "Resolving any missing dependencies..."
113run "apt-get install -f -y"
114
115log "Done. LibreOffice $LATEST_VERSION installed."
116
install-localsend.sh Raw
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
6set -euo pipefail
7IFS=$'\n\t'
8
9readonly SCRIPT_NAME="${0##*/}"
10DRY_RUN=0
11REPO="localsend/localsend"
12
13usage() {
14 cat <<EOF
15Usage: sudo $SCRIPT_NAME [--dry-run] [--help]
16
17Resolves the latest release of localsend/localsend, downloads the official
18Linux .deb package for your architecture, verifies its SHA-256 against the
19GitHub release asset digest when available, and installs it with apt-get.
20
21If \$GITHUB_TOKEN is set in the environment, it is used to authenticate the
22GitHub API request (avoids rate limits).
23
24Options:
25 --dry-run Print actions without executing.
26 --help, -h Show this help.
27EOF
28}
29
30log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
31warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; }
32die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
33run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; }
34trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR
35
36while (( $# )); 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
43done
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
50case "${ID:-}:${ID_LIKE:-}" in
51 *ubuntu*|*debian*) : ;;
52 *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;;
53esac
54
55ARCH="$(dpkg --print-architecture)"
56case "$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)." ;;
60esac
61log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH"
62
63export DEBIAN_FRONTEND=noninteractive
64log "Installing prerequisites..."
65run "apt-get update -qq"
66run "apt-get install -y curl jq ca-certificates"
67
68GH_HDRS=(-H "Accept: application/vnd.github+json")
69[[ -n "${GITHUB_TOKEN:-}" ]] && GH_HDRS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
70
71log "Querying GitHub API for latest release of $REPO..."
72RELEASE_JSON="$(curl -fsSL "${GH_HDRS[@]}" "https://api.github.com/repos/${REPO}/releases/latest")"
73TAG="$(jq -r '.tag_name // empty' <<<"$RELEASE_JSON")"
74[[ -n "$TAG" ]] || die "Could not parse latest release tag (rate limited? set GITHUB_TOKEN)."
75log "Latest release: $TAG"
76
77ASSET_REGEX="^LocalSend-[^-]+-linux-${ASSET_ARCH}\\.deb$"
78DOWNLOAD_URL="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | .browser_download_url' <<<"$RELEASE_JSON" | head -n1)"
79ASSET_NAME="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | .name' <<<"$RELEASE_JSON" | head -n1)"
80EXPECTED_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
83STAGE="$(mktemp -d -t localsend.XXXXXX)"
84trap 'rm -rf "$STAGE"' EXIT
85DEB="$STAGE/$ASSET_NAME"
86
87log "Downloading $ASSET_NAME..."
88run "curl -fL -o '$DEB' '$DOWNLOAD_URL'"
89
90if (( 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
97fi
98
99if [[ -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."
104else
105 warn "No SHA-256 asset digest in GitHub release metadata; skipping verification."
106fi
107
108log "Installing LocalSend..."
109run "apt-get install -y '$DEB'"
110
111if command -v localsend_app >/dev/null 2>&1; then
112 log "Installed: $(localsend_app --version 2>/dev/null || echo "$TAG")"
113else
114 log "Installed: $TAG"
115fi
116log "Done."
117
install-marktext.sh Raw
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
6set -euo pipefail
7IFS=$'\n\t'
8
9readonly SCRIPT_NAME="${0##*/}"
10DRY_RUN=0
11REPO="marktext/marktext"
12
13usage() {
14 cat <<EOF
15Usage: sudo $SCRIPT_NAME [--dry-run] [--help]
16
17Resolves the latest stable release of marktext/marktext, downloads the official
18Linux amd64 .deb package, verifies its SHA-256 against the GitHub release asset
19digest, and installs it with apt-get.
20
21If \$GITHUB_TOKEN is set in the environment, it is used to authenticate the
22GitHub API request (avoids rate limits).
23
24Options:
25 --dry-run Print actions without executing.
26 --help, -h Show this help.
27EOF
28}
29
30log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
31warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; }
32die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
33run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; }
34trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR
35
36while (( $# )); 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
43done
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
50case "${ID:-}:${ID_LIKE:-}" in
51 *ubuntu*|*debian*) : ;;
52 *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;;
53esac
54
55ARCH="$(dpkg --print-architecture)"
56[[ "$ARCH" == "amd64" ]] || die "MarkText publishes its Linux .deb for amd64 only (detected: $ARCH)."
57log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH"
58
59export DEBIAN_FRONTEND=noninteractive
60log "Installing prerequisites..."
61run "apt-get update -qq"
62run "apt-get install -y curl jq ca-certificates"
63
64GH_HDRS=(-H "Accept: application/vnd.github+json")
65[[ -n "${GITHUB_TOKEN:-}" ]] && GH_HDRS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
66
67log "Querying GitHub API for latest release of $REPO..."
68RELEASE_JSON="$(curl -fsSL "${GH_HDRS[@]}" "https://api.github.com/repos/${REPO}/releases/latest")"
69TAG="$(jq -r '.tag_name // empty' <<<"$RELEASE_JSON")"
70[[ -n "$TAG" ]] || die "Could not parse latest release tag (rate limited? set GITHUB_TOKEN)."
71log "Latest release: $TAG"
72
73ASSET_REGEX='^marktext-linux-[^/]+\.deb$'
74DOWNLOAD_URL="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | .browser_download_url' <<<"$RELEASE_JSON" | head -n1)"
75ASSET_NAME="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | .name' <<<"$RELEASE_JSON" | head -n1)"
76EXPECTED_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
79STAGE="$(mktemp -d -t marktext.XXXXXX)"
80trap 'rm -rf "$STAGE"' EXIT
81DEB="$STAGE/$ASSET_NAME"
82
83log "Downloading $ASSET_NAME..."
84run "curl -fsSL -o '$DEB' '$DOWNLOAD_URL'"
85
86if (( 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
93fi
94
95if [[ -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."
100else
101 warn "No SHA-256 asset digest in GitHub release metadata; skipping verification."
102fi
103
104log "Installing MarkText..."
105run "apt-get install -y '$DEB'"
106
107if command -v marktext >/dev/null 2>&1; then
108 log "Installed: $(marktext --version 2>/dev/null || echo "$TAG")"
109else
110 log "Installed: $TAG"
111fi
112log "Done."
113
install-network_drive.sh Raw
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
7set -euo pipefail
8IFS=$'\n\t'
9
10readonly SCRIPT_NAME="${0##*/}"
11DRY_RUN=0
12SERVER_ADDR=""
13SMB_USER=""
14SMB_PASS=""
15MASTER_DIR="/mnt/Synology"
16
17usage() {
18 cat <<EOF
19Usage: sudo $SCRIPT_NAME [options]
20
21Discovers SMB shares on a Synology NAS and adds them to /etc/fstab so they
22auto-mount under $MASTER_DIR. Creates a desktop launcher and (best-effort)
23pins it to the top of the GNOME Dock.
24
25Options:
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.
33EOF
34}
35
36log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
37warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; }
38die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
39run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; }
40trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR
41
42PASS_FROM_STDIN=0
43while (( $# )); 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
54done
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
61case "${ID:-}:${ID_LIKE:-}" in
62 *ubuntu*|*debian*) : ;;
63 *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;;
64esac
65
66ACTUAL_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.
69USER_ENTRY="$(getent passwd "$ACTUAL_USER")" || die "User '$ACTUAL_USER' not found in passwd."
70USER_HOME="$(awk -F: '{print $6}' <<<"$USER_ENTRY")"
71USER_ID="$(awk -F: '{print $3}' <<<"$USER_ENTRY")"
72USER_GID="$(awk -F: '{print $4}' <<<"$USER_ENTRY")"
73
74export DEBIAN_FRONTEND=noninteractive
75log "Installing cifs-utils, smbclient..."
76run "apt-get update -qq"
77run "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
81SERVER_ADDR="${SERVER_ADDR#smb://}"
82SERVER_ADDR="${SERVER_ADDR#//}"
83SERVER_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
89if (( PASS_FROM_STDIN )); then
90 IFS= read -r SMB_PASS
91else
92 read -rsp "Synology password: " SMB_PASS </dev/tty
93 echo
94fi
95[[ -n "$SMB_PASS" ]] || die "Password is empty."
96
97# --- Credentials file ---
98CRED_FILE="$USER_HOME/.smbcredentials_synology"
99log "Writing credentials to $CRED_FILE (mode 0600)..."
100if (( DRY_RUN )); then
101 printf ' DRY-RUN: write %s\n' "$CRED_FILE"
102else
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"
110fi
111
112# --- Query shares ---
113log "Discovering shares on //$SERVER_ADDR..."
114if (( DRY_RUN )); then
115 SHARE_LIST=$'home\nphoto\nvideo'
116else
117 SHARE_LIST="$(smbclient -L "//$SERVER_ADDR" -U "$SMB_USER%$SMB_PASS" -g 2>/dev/null \
118 | awk -F'|' '$1=="Disk" {print $2}' || true)"
119fi
120[[ -n "$SHARE_LIST" ]] || die "No shares returned. Check host, credentials, or network."
121
122# --- Parent mount dir ---
123run "mkdir -p '$MASTER_DIR'"
124run "chown '$USER_ID:$USER_GID' '$MASTER_DIR'"
125
126# --- Backup fstab once ---
127if [[ ! -f /etc/fstab.bak.synology ]]; then
128 run "cp /etc/fstab /etc/fstab.bak.synology"
129 log "Backed up fstab -> /etc/fstab.bak.synology"
130fi
131
132MARK_BEGIN="# >>> synology-master >>> (managed by ${SCRIPT_NAME})"
133MARK_END="# <<< synology-master <<<"
134# Remove any previous managed block (so re-run replaces, not appends).
135if 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
141fi
142
143log "Writing managed block to /etc/fstab..."
144FSTAB_BLOCK="$MARK_BEGIN"$'\n'
145while 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'
151done <<<"$SHARE_LIST"
152FSTAB_BLOCK+="$MARK_END"$'\n'
153
154if (( DRY_RUN )); then
155 printf ' DRY-RUN: append fstab block:\n%s\n' "$FSTAB_BLOCK"
156else
157 printf '%s' "$FSTAB_BLOCK" >>/etc/fstab
158fi
159
160log "Reloading systemd and mounting..."
161run "systemctl daemon-reload"
162run "mount -a -t cifs"
163
164# --- Desktop entry ---
165APPS_DIR="$USER_HOME/.local/share/applications"
166DESKTOP_FILENAME="synology-master.desktop"
167DESKTOP_FILE="$APPS_DIR/$DESKTOP_FILENAME"
168run "install -d -o '$USER_ID' -g '$USER_GID' -m 0755 '$APPS_DIR'"
169
170if (( DRY_RUN )); then
171 printf ' DRY-RUN: write %s\n' "$DESKTOP_FILE"
172else
173 cat >"$DESKTOP_FILE" <<EOF
174[Desktop Entry]
175Name=Synology NAS
176Comment=Open Synology Master Directory
177Exec=xdg-open ${MASTER_DIR}
178Icon=folder-remote
179Terminal=false
180Type=Application
181Categories=Network;FileTools;
182EOF
183 chown "$USER_ID:$USER_GID" "$DESKTOP_FILE"
184 chmod 0755 "$DESKTOP_FILE"
185fi
186
187# --- Best-effort GNOME dock pin ---
188if (( ! 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
203else
204 log "GNOME not detected (or no active dbus session); skipping dock pin."
205fi
206
207log "Done. Shares mounted under $MASTER_DIR."
208
install-obsidian.sh Raw
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
6set -euo pipefail
7IFS=$'\n\t'
8
9readonly SCRIPT_NAME="${0##*/}"
10DRY_RUN=0
11REPO="obsidianmd/obsidian-releases"
12
13usage() {
14 cat <<EOF
15Usage: sudo $SCRIPT_NAME [--dry-run] [--help]
16
17Resolves the latest release of obsidianmd/obsidian-releases, downloads the
18official amd64 .deb package, verifies its SHA-256 against the GitHub release
19asset digest when available, and installs it with apt-get.
20
21If \$GITHUB_TOKEN is set in the environment, it is used to authenticate the
22GitHub API request (avoids rate limits).
23
24Options:
25 --dry-run Print actions without executing.
26 --help, -h Show this help.
27EOF
28}
29
30log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
31warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; }
32die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
33run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; }
34trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR
35
36while (( $# )); 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
43done
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
50case "${ID:-}:${ID_LIKE:-}" in
51 *ubuntu*|*debian*) : ;;
52 *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;;
53esac
54
55ARCH="$(dpkg --print-architecture)"
56[[ "$ARCH" == "amd64" ]] || die "Obsidian publishes a Linux .deb for amd64 only (detected: $ARCH)."
57log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH"
58
59export DEBIAN_FRONTEND=noninteractive
60log "Installing prerequisites..."
61run "apt-get update -qq"
62run "apt-get install -y curl jq ca-certificates"
63
64GH_HDRS=(-H "Accept: application/vnd.github+json")
65[[ -n "${GITHUB_TOKEN:-}" ]] && GH_HDRS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
66
67log "Querying GitHub API for latest release of $REPO..."
68RELEASE_JSON="$(curl -fsSL "${GH_HDRS[@]}" "https://api.github.com/repos/${REPO}/releases/latest")"
69TAG="$(jq -r '.tag_name // empty' <<<"$RELEASE_JSON")"
70[[ -n "$TAG" ]] || die "Could not parse latest release tag (rate limited? set GITHUB_TOKEN)."
71log "Latest release: $TAG"
72
73ASSET_REGEX='^obsidian_[^/]+_amd64\.deb$'
74DOWNLOAD_URL="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | .browser_download_url' <<<"$RELEASE_JSON" | head -n1)"
75ASSET_NAME="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | .name' <<<"$RELEASE_JSON" | head -n1)"
76EXPECTED_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
79STAGE="$(mktemp -d -t obsidian.XXXXXX)"
80trap 'rm -rf "$STAGE"' EXIT
81DEB="$STAGE/$ASSET_NAME"
82
83log "Downloading $ASSET_NAME..."
84run "curl -fsSL -o '$DEB' '$DOWNLOAD_URL'"
85
86if (( 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
93fi
94
95if [[ -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."
100else
101 warn "No SHA-256 asset digest in GitHub release metadata; skipping verification."
102fi
103
104log "Installing Obsidian..."
105run "apt-get install -y '$DEB'"
106
107if (( ! DRY_RUN )) && command -v obsidian >/dev/null 2>&1; then
108 log "Installed: $(obsidian --version 2>/dev/null || echo "$TAG")"
109fi
110log "Done."
111
install-qbittorrent.sh Raw
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
6set -euo pipefail
7IFS=$'\n\t'
8
9readonly SCRIPT_NAME="${0##*/}"
10DRY_RUN=0
11REPO="qbittorrent/qBittorrent"
12
13usage() {
14 cat <<EOF
15Usage: sudo $SCRIPT_NAME [--dry-run] [--help]
16
17Resolves the latest release of qbittorrent/qBittorrent, downloads the official
18Linux x86_64 AppImage, verifies its SHA-256 against the GitHub release asset
19digest when available, and installs it system-wide.
20
21If \$GITHUB_TOKEN is set in the environment, it is used to authenticate the
22GitHub API request (avoids rate limits).
23
24Options:
25 --dry-run Print actions without executing.
26 --help, -h Show this help.
27EOF
28}
29
30log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
31warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; }
32die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
33run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; }
34trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR
35
36while (( $# )); 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
43done
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
50case "${ID:-}:${ID_LIKE:-}" in
51 *ubuntu*|*debian*) : ;;
52 *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;;
53esac
54
55ARCH="$(dpkg --print-architecture)"
56[[ "$ARCH" == "amd64" ]] || die "qBittorrent publishes official Linux AppImages for x86_64/amd64 only (detected: $ARCH)."
57log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH"
58
59export DEBIAN_FRONTEND=noninteractive
60log "Installing prerequisites..."
61run "apt-get update -qq"
62run "apt-get install -y curl jq ca-certificates desktop-file-utils shared-mime-info hicolor-icon-theme"
63
64GH_HDRS=(-H "Accept: application/vnd.github+json")
65[[ -n "${GITHUB_TOKEN:-}" ]] && GH_HDRS+=(-H "Authorization: Bearer ${GITHUB_TOKEN}")
66
67log "Querying GitHub API for latest release of $REPO..."
68RELEASE_JSON="$(curl -fsSL "${GH_HDRS[@]}" "https://api.github.com/repos/${REPO}/releases/latest")"
69TAG="$(jq -r '.tag_name // empty' <<<"$RELEASE_JSON")"
70[[ -n "$TAG" ]] || die "Could not parse latest release tag (rate limited? set GITHUB_TOKEN)."
71VERSION="${TAG#release-}"
72log "Latest release: $TAG"
73
74ASSET_REGEX='^qbittorrent-[0-9][^/]*_x86_64\.AppImage$'
75DOWNLOAD_URL="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | select(.name | test("_lt20_") | not) | .browser_download_url' <<<"$RELEASE_JSON" | head -n1)"
76ASSET_NAME="$(jq -r --arg re "$ASSET_REGEX" '.assets[] | select(.name | test($re)) | select(.name | test("_lt20_") | not) | .name' <<<"$RELEASE_JSON" | head -n1)"
77EXPECTED_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
80STAGE="$(mktemp -d -t qbittorrent.XXXXXX)"
81trap 'rm -rf "$STAGE"' EXIT
82APPIMAGE="$STAGE/$ASSET_NAME"
83
84log "Downloading $ASSET_NAME..."
85run "curl -fL -o '$APPIMAGE' '$DOWNLOAD_URL'"
86
87if (( 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
96fi
97
98if [[ -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."
103else
104 warn "No SHA-256 asset digest in GitHub release metadata; skipping verification."
105fi
106
107log "Installing qBittorrent AppImage..."
108run "install -d -m 0755 /opt/qbittorrent"
109run "install -m 0755 '$APPIMAGE' /opt/qbittorrent/qbittorrent"
110run "ln -sfn /opt/qbittorrent/qbittorrent /usr/local/bin/qbittorrent"
111
112log "Extracting desktop metadata and icons..."
113EXTRACT_DIR="$STAGE/extract"
114run "install -d -m 0755 '$EXTRACT_DIR'"
115(
116 cd "$EXTRACT_DIR"
117 /opt/qbittorrent/qbittorrent --appimage-extract >/dev/null
118)
119
120run "install -d -m 0755 /usr/share/icons/hicolor/scalable/apps"
121run "install -m 0644 '$EXTRACT_DIR/squashfs-root/usr/share/icons/hicolor/scalable/apps/qbittorrent.svg' /usr/share/icons/hicolor/scalable/apps/qbittorrent.svg"
122for 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'"
127done
128
129log "Installing desktop launcher..."
130cat >/usr/share/applications/org.qbittorrent.qBittorrent.desktop <<EOF
131[Desktop Entry]
132Type=Application
133Name=qBittorrent
134GenericName=BitTorrent client
135Comment=Download and share files over BitTorrent
136Exec=/opt/qbittorrent/qbittorrent %U
137Icon=qbittorrent
138Terminal=false
139Categories=Network;FileTransfer;P2P;Qt;
140MimeType=application/x-bittorrent;x-scheme-handler/magnet;
141StartupNotify=false
142StartupWMClass=qbittorrent
143Keywords=bittorrent;torrent;magnet;download;p2p;
144SingleMainWindow=true
145EOF
146
147run "desktop-file-validate /usr/share/applications/org.qbittorrent.qBittorrent.desktop"
148run "update-desktop-database /usr/share/applications"
149run "update-mime-database /usr/share/mime"
150run "gtk-update-icon-cache -f -t /usr/share/icons/hicolor >/dev/null 2>&1 || true"
151
152if 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"
155fi
156
157if command -v qbittorrent >/dev/null 2>&1; then
158 log "Installed: $(qbittorrent --version 2>/dev/null || echo "$VERSION")"
159fi
160log "Done."
161
install-screenshot-cleanup-cron.sh Raw
1#!/usr/bin/env bash
2# install-screenshot-cleanup-cron.sh — Clear ~/Pictures/Screenshots every 5 minutes.
3# Idempotent per-user crontab installer.
4
5set -euo pipefail
6IFS=$'\n\t'
7
8readonly SCRIPT_NAME="${0##*/}"
9readonly BEGIN_MARKER="# BEGIN managed by install-screenshot-cleanup-cron.sh"
10readonly END_MARKER="# END managed by install-screenshot-cleanup-cron.sh"
11DRY_RUN=0
12
13usage() {
14 cat <<EOF
15Usage: $SCRIPT_NAME [--dry-run] [--help]
16
17Installs a per-user cron job that deletes everything inside:
18
19 \$HOME/Pictures/Screenshots
20
21every 5 minutes. The Screenshots directory itself is preserved.
22
23Options:
24 --dry-run Print actions without executing.
25 --help, -h Show this help.
26EOF
27}
28
29log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
30die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
31
32while (( $# )); 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
39done
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
43for tool in crontab awk mktemp mkdir; do
44 command -v "$tool" >/dev/null 2>&1 || die "Missing required tool: $tool"
45done
46
47SCREENSHOT_DIR="$HOME/Pictures/Screenshots"
48CRON_LINE="*/5 * * * * find \"$SCREENSHOT_DIR\" -mindepth 1 -delete"
49STAGE="$(mktemp -d -t screenshot-cron.XXXXXX)"
50trap 'rm -rf "$STAGE"' EXIT
51
52CURRENT="$STAGE/current"
53NEXT="$STAGE/next"
54crontab -l >"$CURRENT" 2>/dev/null || true
55
56awk -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
72if (( 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
77fi
78
79mkdir -p "$SCREENSHOT_DIR"
80crontab "$NEXT"
81
82log "Installed screenshot cleanup cron job:"
83printf ' %s\n' "$CRON_LINE"
84log "Done."
85
install-telegram.sh Raw
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
5set -euo pipefail
6IFS=$'\n\t'
7
8readonly SCRIPT_NAME="${0##*/}"
9DRY_RUN=0
10DOWNLOAD_URL="https://telegram.org/dl/desktop/linux"
11
12usage() {
13 cat <<EOF
14Usage: sudo $SCRIPT_NAME [--dry-run] [--help]
15
16Downloads Telegram Desktop from Telegram's official latest Linux endpoint,
17installs it to /opt/Telegram, creates /usr/local/bin/telegram-desktop, and
18installs a desktop launcher.
19
20Options:
21 --dry-run Print actions without executing.
22 --help, -h Show this help.
23EOF
24}
25
26log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
27die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
28run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; }
29trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR
30
31while (( $# )); 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
38done
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
45case "${ID:-}:${ID_LIKE:-}" in
46 *ubuntu*|*debian*) : ;;
47 *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;;
48esac
49
50MACHINE="$(uname -m)"
51case "$MACHINE" in
52 x86_64|amd64) : ;;
53 *) die "Telegram's official Linux desktop tarball targets x86_64 (detected: $MACHINE)." ;;
54esac
55log "Detected: ${PRETTY_NAME:-unknown}, arch: $MACHINE"
56
57export DEBIAN_FRONTEND=noninteractive
58log "Installing prerequisites..."
59run "apt-get update -qq"
60run "apt-get install -y curl ca-certificates tar xz-utils desktop-file-utils hicolor-icon-theme"
61
62STAGE="$(mktemp -d -t telegram.XXXXXX)"
63trap 'rm -rf "$STAGE"' EXIT
64ARCHIVE="$STAGE/telegram.tar.xz"
65
66log "Downloading latest Telegram Desktop..."
67run "curl -fL -o '$ARCHIVE' '$DOWNLOAD_URL'"
68
69if (( 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
75fi
76
77log "Extracting Telegram..."
78run "tar -xJf '$ARCHIVE' -C '$STAGE'"
79[[ -x "$STAGE/Telegram/Telegram" ]] || die "Unexpected archive layout: Telegram/Telegram not found."
80
81log "Installing Telegram..."
82run "rm -rf /opt/Telegram"
83run "install -d -m 0755 /opt"
84run "cp -a '$STAGE/Telegram' /opt/Telegram"
85run "ln -sfn /opt/Telegram/Telegram /usr/local/bin/telegram-desktop"
86
87run "install -d -m 0755 /usr/share/applications /usr/share/icons/hicolor/256x256/apps"
88if [[ -f /opt/Telegram/telegram.png ]]; then
89 run "install -m 0644 /opt/Telegram/telegram.png /usr/share/icons/hicolor/256x256/apps/telegram.png"
90fi
91
92cat >/usr/share/applications/telegram-desktop.desktop <<'EOF'
93[Desktop Entry]
94Type=Application
95Name=Telegram Desktop
96GenericName=Messenger
97Comment=Official Telegram desktop messaging app
98Exec=/opt/Telegram/Telegram -- %u
99Icon=telegram
100Terminal=false
101Categories=Network;InstantMessaging;Chat;
102MimeType=x-scheme-handler/tg;
103StartupNotify=false
104StartupWMClass=TelegramDesktop
105Keywords=telegram;chat;messenger;
106EOF
107
108run "desktop-file-validate /usr/share/applications/telegram-desktop.desktop"
109run "update-desktop-database /usr/share/applications"
110run "gtk-update-icon-cache -f -t /usr/share/icons/hicolor >/dev/null 2>&1 || true"
111if command -v xdg-mime >/dev/null 2>&1; then
112 run "xdg-mime default telegram-desktop.desktop x-scheme-handler/tg || true"
113fi
114
115log "Installed: $(/opt/Telegram/Telegram --version 2>/dev/null || echo Telegram Desktop)"
116log "Done."
117
install-thunderbird-backup-cron.sh Raw
1#!/usr/bin/env bash
2set -euo pipefail
3
4readonly SCRIPT_NAME="${0##*/}"
5readonly BACKUP_URL='https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD/backup-thunderbird-to-synology.sh'
6readonly CRON_MARKER='# thunderbird-synology-backup'
7readonly 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"
8DRY_RUN=0
9
10usage() {
11 cat <<EOF
12Usage: $SCRIPT_NAME [--dry-run] [--help]
13
14Installs an hourly user cron job that fetches and executes the hosted
15Thunderbird-to-Synology backup script without storing the script locally.
16
17Options:
18 --dry-run Print the cron entry without installing it.
19 --help, -h Show this help.
20EOF
21}
22
23die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
24
25while (( $# )); 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
32done
33
34(( EUID != 0 )) || die "Do not run as root. Run as your normal user."
35
36for 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
42done
43
44if (( DRY_RUN )); then
45 printf '%s\n' "$CRON_ENTRY"
46 exit 0
47fi
48
49mkdir -p "$HOME/.local/state"
50
51current_crontab="$(crontab -l 2>/dev/null || true)"
52filtered_crontab="$(printf '%s\n' "$current_crontab" | grep -Fv -e "$CRON_MARKER" -e 'backup-thunderbird-to-synology.sh' || true)"
53
54if [[ -n "$filtered_crontab" ]]; then
55 printf '%s\n%s\n' "$filtered_crontab" "$CRON_ENTRY" | crontab -
56else
57 printf '%s\n' "$CRON_ENTRY" | crontab -
58fi
59
60installed_count="$(crontab -l | grep -Fxc "$CRON_ENTRY" || true)"
61[[ "$installed_count" == 1 ]] || die "Unable to verify the hourly cron entry."
62
63printf 'Installed hourly Thunderbird backup cron job.\n'
64printf '%s\n' "$CRON_ENTRY"
65
install-thunderbird.sh Raw
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
7set -euo pipefail
8IFS=$'\n\t'
9
10readonly SCRIPT_NAME="${0##*/}"
11DRY_RUN=0
12
13usage() {
14 cat <<EOF
15Usage: sudo $SCRIPT_NAME [--dry-run] [--help]
16
17Installs 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
22Options:
23 --dry-run Print actions without executing.
24 --help, -h Show this help.
25EOF
26}
27
28log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
29warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; }
30die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
31run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; }
32trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR
33
34while (( $# )); 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
41done
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
48log "Detected: ${PRETTY_NAME:-unknown} (id=${ID:-?})"
49
50export DEBIAN_FRONTEND=noninteractive
51
52case "${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'
79Package: thunderbird*
80Pin: release o=LP-PPA-mozillateam
81Pin-Priority: 1001
82EOF
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 ;;
100esac
101
102log "Done."
103
install-vscode.sh Raw
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
5set -euo pipefail
6IFS=$'\n\t'
7
8readonly SCRIPT_NAME="${0##*/}"
9DRY_RUN=0
10INSIDERS=0
11
12usage() {
13 cat <<EOF
14Usage: sudo $SCRIPT_NAME [--dry-run] [--insiders] [--help]
15
16Configures the official Microsoft 'vscode' APT repository (signed-by keyring)
17and installs Visual Studio Code. Idempotent: safe to re-run.
18
19Options:
20 --insiders Install the 'code-insiders' build instead of stable 'code'.
21 --dry-run Print actions without executing.
22 --help, -h Show this help.
23EOF
24}
25
26log() { printf '\033[1;34m[%s]\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*"; }
27warn() { printf '\033[1;33m[%s] WARN:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; }
28die() { printf '\033[1;31m[%s] ERROR:\033[0m %s\n' "${SCRIPT_NAME%.sh}" "$*" >&2; exit 1; }
29run() { if (( DRY_RUN )); then printf ' DRY-RUN: %s\n' "$*"; else eval "$@"; fi; }
30trap 'rc=$?; (( rc )) && printf "\033[1;31m[%s] failed at line %s (exit %d)\033[0m\n" "${SCRIPT_NAME%.sh}" "$LINENO" "$rc" >&2' ERR
31
32while (( $# )); 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
40done
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
47case "${ID:-}:${ID_LIKE:-}" in
48 *ubuntu*|*debian*) : ;;
49 *) die "Unsupported distro: ${PRETTY_NAME:-unknown}." ;;
50esac
51
52ARCH="$(dpkg --print-architecture)"
53case "$ARCH" in
54 amd64|arm64|armhf) : ;;
55 *) die "VS Code is published for amd64/arm64/armhf (detected: $ARCH)." ;;
56esac
57log "Detected: ${PRETTY_NAME:-unknown}, arch: $ARCH"
58
59export DEBIAN_FRONTEND=noninteractive
60
61KEYRING=/etc/apt/keyrings/packages.microsoft.gpg
62SOURCES=/etc/apt/sources.list.d/vscode.list
63PKG=$([[ $INSIDERS -eq 1 ]] && echo code-insiders || echo code)
64
65log "Installing prerequisites..."
66run "apt-get update -qq"
67run "apt-get install -y wget gpg apt-transport-https ca-certificates"
68
69log "Configuring Microsoft vscode APT repository..."
70run "install -d -m 0755 /etc/apt/keyrings"
71if [[ ! -s "$KEYRING" ]]; then
72 run "wget -qO- https://packages.microsoft.com/keys/microsoft.asc | gpg --dearmor -o '$KEYRING'"
73 run "chmod 0644 '$KEYRING'"
74fi
75
76DESIRED_SRC="deb [arch=amd64,arm64,armhf signed-by=${KEYRING}] https://packages.microsoft.com/repos/code stable main"
77if [[ ! -f "$SOURCES" ]] || ! grep -qxF "$DESIRED_SRC" "$SOURCES"; then
78 run "printf '%s\n' '$DESIRED_SRC' > '$SOURCES'"
79fi
80
81log "Installing $PKG..."
82run "apt-get update -qq"
83run "apt-get install -y '$PKG'"
84
85if (( ! DRY_RUN )) && command -v "$PKG" >/dev/null 2>&1; then
86 log "Installed: $("$PKG" --version 2>/dev/null | head -n1 || echo "$PKG")"
87fi
88log "Done."
89
menu.sh Raw
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.
21set -uo pipefail
22IFS=$'\n\t'
23
24readonly SCRIPT_NAME="${0##*/}"
25readonly BASE_URL='https://opengist.resetrix.work/weehong/2de15ba0106a475fa41215159203a63b/raw/HEAD'
26
27log() { printf '\033[1;34m[menu]\033[0m %s\n' "$*"; }
28warn() { printf '\033[1;33m[menu] WARN:\033[0m %s\n' "$*" >&2; }
29err() { printf '\033[1;31m[menu] ERROR:\033[0m %s\n' "$*" >&2; }
30hr() { 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.
34if (( 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
37fi
38
39# Sanity-check tools we depend on.
40for tool in curl bash mktemp; do
41 command -v "$tool" >/dev/null 2>&1 || { err "Missing required tool: $tool"; exit 1; }
42done
43
44# Distro check (warn-only; sub-scripts enforce strictly).
45if [[ -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
52fi
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.
56prime_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).
70run_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.)
108OPTIONS=(
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.
138SOFTWARE_NUMS=(1 2 3 4 5 6 7 9 10 11 12 13 14 15 16 17 18 19 20)
139
140package_is_installed() {
141 [[ "$(dpkg-query -W -f='${db:Status-Abbrev}' "$1" 2>/dev/null || true)" == "ii " ]]
142}
143
144package_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
153software_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
183print_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.
220resolve_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
232ALL_NUMS=()
233for 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")
236done
237
238prime_sudo || exit 1
239
240while 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
322done
323
thunderbird-backup.conf Raw
1# Thunderbird backup settings.
2# This file is fetched by backup-thunderbird-to-synology.sh from OpenGist.
3# All paths should be absolute.
4
5SOURCE="/home/vernon/.thunderbird/"
6DESTINATION="/mnt/Synology/Backup/Applications/Thunderbird/"
7MOUNTPOINT="/mnt/Synology/Backup"
8LOG_FILE="/home/vernon/.local/state/thunderbird-backup.log"
9LOCK_FILE="/home/vernon/.cache/thunderbird-backup.lock"
10
thunderbird-backup.conf.example Raw
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
6SOURCE="/home/vernon/.thunderbird/"
7DESTINATION="/mnt/Synology/Backup/Applications/Thunderbird/"
8MOUNTPOINT="/mnt/Synology/Backup"
9LOG_FILE="/home/vernon/.local/state/thunderbird-backup.log"
10LOCK_FILE="/home/vernon/.cache/thunderbird-backup.lock"
11
timedatectl-fix.sh Raw
1#!/usr/bin/env bash
2
3set -euo pipefail
4
5echo "======================================"
6echo " Ubuntu Dual-Boot Time Fix Script"
7echo " (UTC Method - Highly Recommended)"
8echo "======================================"
9echo
10
11# Ensure running as root
12if [[ $EUID -ne 0 ]]; then
13 echo "Please run as root:"
14 echo "sudo bash fix-time.sh"
15 exit 1
16fi
17
18# Detect current timezone instead of hardcoding
19CURRENT_TZ=$(timedatectl show -p Timezone --value || echo "UTC")
20
21echo "[1/5] Ensuring timezone is set to $CURRENT_TZ..."
22timedatectl set-timezone "$CURRENT_TZ"
23
24echo "[2/5] Configuring RTC to use UTC (to prevent dual-boot conflicts)..."
25# '0' sets the hardware clock to UTC
26timedatectl set-local-rtc 0 --adjust-system-clock
27
28echo "[3/5] Enabling NTP synchronization..."
29timedatectl set-ntp true
30
31echo "[4/5] Restarting time sync daemon (if applicable)..."
32systemctl restart systemd-timesyncd 2>/dev/null || true
33
34echo "[5/5] Final time configuration:"
35echo
36timedatectl
37
38echo
39echo "======================================"
40echo " SUCCESS - LINUX CONFIGURED"
41echo "======================================"
42echo
43echo "Your system is now configured with:"
44echo " - Timezone: $CURRENT_TZ"
45echo " - RTC stored in UTC"
46echo " - NTP synchronization enabled"
47echo
48
49cat <<'EOF'
50--------------------------------------------------
51WINDOWS CONFIGURATION (REQUIRED)
52--------------------------------------------------
53Linux is now correctly expecting the hardware clock to be in UTC.
54To make Windows do the same and stop the time from resetting,
55you MUST run the following command in Windows.
56
571. Boot into Windows.
582. Open Command Prompt as Administrator.
593. Run this exact command:
60
61reg add "HKEY_LOCAL_MACHINE\System\CurrentControlSet\Control\TimeZoneInformation" /v RealTimeIsUniversal /d 1 /t REG_DWORD /f
62
634. Restart Windows and sync the time in Settings one last time.
64EOF