diff --git a/README.md b/README.md index 5b75ab7..8d22d32 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ and nothing to re-apply after somebody else's release. | `branding/` | icon, ASCII art, boot splash | | `wallpapers/` | 103 wallpapers, previewed in [the gallery](wallpaper-gallery/index.md) | | `session/` | wayland session entry and uwsm environment | +| `install/` | the installer steps `install.sh` runs | | `docs/` | everything below | ## Docs @@ -51,6 +52,7 @@ and nothing to re-apply after somebody else's release. | [themes.md](docs/themes.md) | the palette pipeline and how to add a theme | | [widgets.md](docs/widgets.md) | the ported GTK widgets and where they went | | [menu.md](docs/menu.md) | the menu tree and what was trimmed | +| [install.md](docs/install.md) | what the installer does, step by step | | [packages.md](docs/packages.md) | leaving the Omarchy repository, and updating | | [upstream.md](docs/upstream.md) | the fork base, for diffing later | @@ -61,16 +63,34 @@ and nothing to re-apply after somebody else's release. files that must sit under `~/.config` get copied. ```bash -./install.sh # link and deploy -./install.sh --check # report what would change, write nothing -./install.sh --force # overwrite files with local changes +./install.sh # directories, packages, config, services, login screen +./install.sh --check # report what would change, write nothing +./install.sh --force # overwrite files with local changes +./install.sh --autologin # log this user straight into the Blob session +./install.sh --no-launch # do not open the login screen at the end ``` -Then log out and pick the Blob session. +It runs these steps, in this order, and every one of them is idempotent: -To back out, `./uninstall.sh` removes the session entry, the config, and the -symlink, restoring any `.bak` the installer made. The checkout, the wallpapers, -and the themes stay where they are. +| Step | What it does | +| --- | --- | +| 1. `BLOB_PATH` | symlinks `~/.local/share/blob` to this checkout | +| 2. Directories | creates the 19 directories the shell and scripts expect | +| 3. Packages | installs what is missing from `packages/`, bootstrapping `yay` first on a machine that has no AUR helper | +| 4. Config | copies `hypr/`, the themed app configs, `blob/shell.json`, the uwsm environment, and the icon font into `~/.config` | +| 5. User units | installs the session services into `~/.config/systemd/user` and enables them | +| 6. systemd drop-ins | writes the shutdown timeouts, oomd thresholds, plocate prune paths, and sleep hooks under `/etc` | +| 7. System services | enables NetworkManager, resolved, bluetooth, cups, avahi, docker, power-profiles-daemon, and oomd, and masks `NetworkManager-wait-online` | +| 8. Login screen | installs the session entry and the SDDM config, enables `sddm.service`, and sets the default target to `graphical.target` | +| 9. Launch | starts the login screen, so the desktop comes up without a reboot | + +Step 8 is the one a plain Arch install cannot do without: SDDM's stock Wayland +greeter runs on `weston`, which Blob does not install, so the greeter is pointed +at Hyprland instead. [install.md](docs/install.md) covers each step in full. + +To back out, `./uninstall.sh` removes the session entry, the config, the user +services, and the symlink, restoring any `.bak` the installer made. The +checkout, the wallpapers, and the themes stay where they are. ## Regenerating docs diff --git a/default/sddm/10-blob.conf b/default/sddm/10-blob.conf new file mode 100644 index 0000000..671e75e --- /dev/null +++ b/default/sddm/10-blob.conf @@ -0,0 +1,9 @@ +[General] +DisplayServer=wayland + +[Wayland] +CompositorCommand=start-hyprland -- --config /etc/sddm/hyprland-greeter.lua + +[Users] +RememberLastUser=true +RememberLastSession=true diff --git a/default/sddm/hyprland-greeter.lua b/default/sddm/hyprland-greeter.lua new file mode 100644 index 0000000..14d8870 --- /dev/null +++ b/default/sddm/hyprland-greeter.lua @@ -0,0 +1,11 @@ +hl.config({ + misc = { + disable_hyprland_logo = true, + disable_splash_rendering = true, + force_default_wallpaper = 0, + }, + + animations = { + enabled = false, + }, +}) diff --git a/default/systemd/oomd.conf.d/10-blob.conf b/default/systemd/oomd.conf.d/10-blob.conf new file mode 100644 index 0000000..5a16c34 --- /dev/null +++ b/default/systemd/oomd.conf.d/10-blob.conf @@ -0,0 +1,21 @@ +# Without an OOM daemon there is nothing between "memory is tight" and +# "processes start dying at random". The kernel OOM killer only fires once an +# allocation has already failed outright, which is far too late for the +# desktop: the machine thrashes in reclaim until something fails somewhere +# fatal. Seen in practice as Hyprland taking SIGBUS mid-memcpy from a Wayland +# client's shm pool, because the client was being torn down by the same memory +# pressure. One app misbehaving should not cost the whole session. +# +# systemd-oomd keys on PSI stall time rather than on free pages, so it acts +# while the machine is still thrashing instead of after an allocation failed. +# Deliberately not earlyoom: that fires only when free RAM *and* free swap are +# both under threshold, so a large, mostly-unused swapfile keeps it asleep +# through exactly this failure. +# +# 50% means half of a ten-second window was spent stalled on memory reclaim. +# Held for 20s straight, the desktop is already unusable and losing one app is +# the cheaper outcome. Which cgroups are eligible is set separately, in +# ~/.config/systemd/user/app.slice.d/10-oomd.conf. +[OOM] +DefaultMemoryPressureDurationSec=20s +DefaultMemoryPressureLimit=50% diff --git a/default/systemd/user/blob-crash-watch.service b/default/systemd/user/blob-crash-watch.service index da6906b..e2cd542 100644 --- a/default/systemd/user/blob-crash-watch.service +++ b/default/systemd/user/blob-crash-watch.service @@ -11,7 +11,7 @@ ConditionPathExists=!%h/.local/state/blob/toggles/crash-capture-off [Service] Type=simple -ExecStart=/usr/bin/blob-crash-watch +ExecStart=%h/.local/share/blob/bin/blob-crash-watch Restart=always RestartSec=5 diff --git a/default/systemd/user/blob-sleep-lock.service b/default/systemd/user/blob-sleep-lock.service index 377975c..fd757df 100644 --- a/default/systemd/user/blob-sleep-lock.service +++ b/default/systemd/user/blob-sleep-lock.service @@ -11,7 +11,7 @@ ConditionEnvironment=WAYLAND_DISPLAY [Service] Type=simple -ExecStart=/usr/bin/blob-system-sleep-monitor +ExecStart=%h/.local/share/blob/bin/blob-system-sleep-monitor Restart=always RestartSec=2 diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..5e4b4f9 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,75 @@ +# Install + +`./install.sh` takes a machine with nothing but Arch on it to a running Blob +desktop. Every step is idempotent, reports what it did, and can be previewed +with `--check`. + +## Layout + +| Path | Role | +| --- | --- | +| `install.sh` | flags and the order the steps run in | +| `install/helpers/lib.sh` | reporting, file copies, unit queries | +| `install/helpers/as-root.sh` | `as_root`, a no-op when already root | +| `install/steps/directories.sh` | the directories the shell and scripts expect | +| `install/steps/packages.sh` | repo and AUR packages, and the yay bootstrap | +| `install/steps/config.sh` | the `BLOB_PATH` symlink and everything under `~/.config` | +| `install/steps/services.sh` | system services, systemd drop-ins, user units | +| `install/steps/login.sh` | SDDM, the session entry, and the first launch | + +## Steps + +1. **`BLOB_PATH`** is symlinked to the checkout at `~/.local/share/blob`. Nothing + under `bin/`, `shell/`, `themes/` or `default/` is ever copied. +2. **Directories** are created, including `~/.config/systemd/user/app.slice.d` + and `~/wallpapers`. +3. **Packages** come from `packages/blob.packages` and + `packages/blob-aur.packages`. A machine with no AUR helper gets `yay-bin` + built once with `makepkg`. See [packages.md](packages.md). +4. **Config** is copied into `~/.config`: `hypr/`, the themed app configs, + `blob/shell.json`, the uwsm environment, and the icon font. +5. **User units** land in `~/.config/systemd/user` and are enabled, not started: + they are all `WantedBy=graphical-session.target`, so they come up with the + session. `blob-speaker-tuning.service` is left out, because + `blob-audio-tuning` installs it only on machines with a tuning profile. +6. **systemd drop-ins** are written under `/etc`: the shutdown timeouts, the + oomd pressure thresholds, the plocate prune paths, and the sleep hooks. +7. **System services** are enabled: NetworkManager, resolved, bluetooth, cups, + avahi, docker, power-profiles-daemon, and oomd. + `NetworkManager-wait-online.service` is masked so a slow DHCP lease cannot + hold up the login screen. +8. **The login screen** is SDDM. Three pieces have to be in place: + + | File | Why | + | --- | --- | + | `/usr/local/share/wayland-sessions/blob.desktop` | the session SDDM offers | + | `/etc/sddm.conf.d/10-blob.conf` | Wayland greeter, run on Hyprland, remember the last session | + | `/etc/sddm/hyprland-greeter.lua` | the greeter's own tiny Hyprland config | + + The greeter config matters more than it looks: SDDM's stock Wayland greeter + runs on `weston`, which Blob does not install, so a default SDDM would fail + to draw anything. `sddm.service` is enabled and the default systemd target is + set to `graphical.target`, which an Arch install without a display manager + does not have. +9. **Launch.** When the installer was run from a console and nothing graphical + is running, it starts `sddm.service` so the desktop appears without a reboot. + `--no-launch` prints the command instead. `--autologin` writes + `/etc/sddm.conf.d/99-blob-autologin.conf` for the current user. + +## Flags + +| Flag | Effect | +| --- | --- | +| `--check` | report every change, write nothing, exit 1 if anything differs | +| `--force` | overwrite files that differ from the repo copy | +| `--skip-packages` | directories, config and services only | +| `--autologin` | skip the SDDM prompt for this user | +| `--no-launch` | leave the login screen for the next reboot | + +## Uninstall + +`./uninstall.sh` removes the config, the user units, the `/etc` drop-ins, the +session entry and the symlink, restoring any `.bak` the installer left. +`--keep-session` keeps the session entry and the SDDM config, `--keep-state` +keeps `~/.local/state/blob`. `sddm.service` is left enabled either way, so the +machine still reaches a login screen. diff --git a/install.sh b/install.sh index 57dbe50..ec6b709 100755 --- a/install.sh +++ b/install.sh @@ -5,22 +5,27 @@ set -e repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" blob_path="$HOME/.local/share/blob" config_dir="$HOME/.config" -state_dir="$HOME/.local/state/blob" -session_dir="/usr/share/wayland-sessions" font_dir="$HOME/.local/share/fonts" force=false check=false skip_packages=false +no_launch=false +autologin=false changes=0 usage() { cat < 0 )); do --force) force=true; shift ;; --check) check=true; shift ;; --skip-packages) skip_packages=true; shift ;; + --autologin) autologin=true; shift ;; + --no-launch) no_launch=true; shift ;; --help) usage ;; *) echo "Unknown option: $1" >&2; usage ;; esac done -say() { - printf '%s\n' "$1" -} - -note_change() { - changes=$((changes + 1)) -} - -directories=( - "$HOME/.config/blob/hooks" - "$HOME/.config/blob/extensions" - "$HOME/.config/blob/plugins" - "$HOME/.config/blob/themed" - "$HOME/.config/blob/themes" - "$HOME/.config/blob/branding" - "$HOME/.config/hypr" - "$HOME/.config/uwsm/env.d" - "$HOME/.local/state/blob/toggles/hypr" - "$HOME/.local/state/blob/indicators" - "$HOME/.local/state/blob/notifications/history" - "$HOME/.local/state/blob/notifications/images" - "$HOME/.local/state/blob/backgrounds" - "$HOME/.local/state/blob/current" - "$HOME/.local/share/fonts" - "$HOME/.local/share/applications" - "$HOME/.local/share/icons/hicolor/256x256/apps" - "$HOME/wallpapers" -) - -create_directories() { - local missing=() - local dir - for dir in "${directories[@]}"; do - [[ -d $dir ]] || missing+=("$dir") - done - - if (( ${#missing[@]} == 0 )); then - say "ok directories" - return 0 - fi - - note_change - if [[ $check == true ]]; then - say "would create ${#missing[@]} directory(ies):" - printf ' %s\n' "${missing[@]}" - return 0 - fi - - mkdir -p "${missing[@]}" - say "mkdir ${#missing[@]} directory(ies)" -} - -read_package_list() { - [[ -f $1 ]] || return 0 - grep -vE '^[[:space:]]*#|^[[:space:]]*$' "$1" -} - -# A list entry may be "wanted|already-fine", which asks for the first name but -# treats either as satisfying the requirement. That is how the AUR replacement -# for a package still shipped by another repository avoids a file conflict. -missing_packages() { - local entry wanted alternative - for entry in $(read_package_list "$1"); do - wanted=${entry%%|*} - alternative=${entry#*|} - pacman -Q "$wanted" &>/dev/null && continue - [[ $alternative != "$entry" ]] && pacman -Q "$alternative" &>/dev/null && continue - printf '%s\n' "$wanted" - done -} - -install_packages() { - if [[ $skip_packages == true ]]; then - say "skip packages (--skip-packages)" - return 0 - fi - - local -a repo_missing aur_missing - mapfile -t repo_missing < <(missing_packages "$repo_dir/packages/blob.packages") - mapfile -t aur_missing < <(missing_packages "$repo_dir/packages/blob-aur.packages") - - if (( ${#repo_missing[@]} == 0 && ${#aur_missing[@]} == 0 )); then - say "ok packages" - return 0 - fi - - note_change - if [[ $check == true ]]; then - (( ${#repo_missing[@]} )) && say "would install ${#repo_missing[@]} repo package(s): ${repo_missing[*]}" - (( ${#aur_missing[@]} )) && say "would install ${#aur_missing[@]} AUR package(s): ${aur_missing[*]}" - return 0 - fi - - blob_sudo_keepalive - - if (( ${#repo_missing[@]} )); then - say "install ${#repo_missing[@]} repo package(s)" - sudo pacman -S --needed --noconfirm "${repo_missing[@]}" - fi - - if (( ${#aur_missing[@]} )); then - ensure_aur_helper || { - say "WARN no AUR helper; skipping ${#aur_missing[@]} AUR package(s): ${aur_missing[*]}" - return 0 - } - say "install ${#aur_missing[@]} AUR package(s)" - yay -S --needed --noconfirm "${aur_missing[@]}" - fi -} - -# A fresh Arch install has no AUR helper, and yay is not in the official -# repositories, so it is built from the AUR once with makepkg. Everything after -# this point can then use yay normally. -ensure_aur_helper() { - command -v yay &>/dev/null && return 0 - - say "bootstrap yay from the AUR" - sudo pacman -S --needed --noconfirm base-devel git - - local build_dir - build_dir="$(mktemp -d)" - if ! git clone --depth 1 https://aur.archlinux.org/yay-bin.git "$build_dir/yay-bin"; then - rm -rf "$build_dir" - return 1 - fi - - ( cd "$build_dir/yay-bin" && makepkg -si --noconfirm ) || { - rm -rf "$build_dir" - return 1 - } - - rm -rf "$build_dir" - command -v yay &>/dev/null -} - -blob_sudo_keepalive() { - sudo -v 2>/dev/null || true -} - -# BLOB_PATH is a symlink to this checkout rather than a copy, so bin/, shell/, -# themes/ and default/ are always the working tree. Every path the shell -# resolves as $BLOB_PATH/... therefore needs no install step at all. -link_blob_path() { - local current="" - [[ -L $blob_path ]] && current="$(readlink -f "$blob_path")" - - if [[ $current == "$repo_dir" ]]; then - say "ok BLOB_PATH -> $repo_dir" - return 0 - fi - - if [[ -e $blob_path && ! -L $blob_path ]]; then - say "WARN $blob_path exists and is not a symlink; move it aside first" - note_change - return 0 - fi - - note_change - if [[ $check == true ]]; then - say "would link BLOB_PATH -> $repo_dir" - return 0 - fi - - ln -sfn "$repo_dir" "$blob_path" - say "link BLOB_PATH -> $repo_dir" -} - -# Copy a file only when the destination is missing or matches what we last -# wrote. A destination that differs is a local edit, reported and kept unless -# --force says otherwise. -install_file() { - local source="$1" dest="$2" label="$3" - - if [[ ! -e $source ]]; then - say "SKIP $label (missing in repo)" - return 0 - fi - - if [[ -e $dest ]] && cmp -s "$source" "$dest"; then - say "ok $label" - return 0 - fi - - note_change - - if [[ -e $dest ]] && [[ $force == false ]]; then - say "DIFF $label (local changes kept; --force to overwrite)" - return 0 - fi - - if [[ $check == true ]]; then - say "would write $label" - return 0 - fi - - mkdir -p "$(dirname "$dest")" - [[ -e $dest ]] && cp "$dest" "$dest.bak" - cp "$source" "$dest" - say "write $label" -} - -install_tree() { - local source="$1" dest="$2" label="$3" - - if [[ ! -d $source ]]; then - say "SKIP $label (missing in repo)" - return 0 - fi - - local relative - while IFS= read -r relative; do - install_file "$source/$relative" "$dest/$relative" "$label/$relative" - done < <(cd "$source" && find . -type f -printf '%P\n' | sort) -} - -install_session_entry() { - local source="$repo_dir/session/blob.desktop" - local dest="$session_dir/blob.desktop" - - if [[ -e $dest ]] && cmp -s "$source" "$dest"; then - say "ok wayland session entry" - return 0 - fi - - note_change - if [[ $check == true ]]; then - say "would install wayland session entry (needs sudo)" - return 0 - fi - - sudo install -Dm644 "$source" "$dest" - say "write wayland session entry" -} +source "$repo_dir/install/helpers/lib.sh" +source "$repo_dir/install/steps/directories.sh" +source "$repo_dir/install/steps/packages.sh" +source "$repo_dir/install/steps/config.sh" +source "$repo_dir/install/steps/services.sh" +source "$repo_dir/install/steps/login.sh" say "Blob installer" say "repo: $repo_dir" @@ -281,39 +61,19 @@ link_blob_path create_directories install_packages -install_tree "$repo_dir/hypr" "$config_dir/hypr" "hypr" -# config/ is the shipped-defaults directory. Everything in it is reachable as -# $BLOB_PATH/config/... so `blob-refresh-config` can reset a file, and most of it -# is also the right thing to deploy on a fresh install. Two subdirectories are -# reference-only and must not be copied into ~/.config: -# -# blob/ the shell defaults; copying them over ~/.config/blob/shell.json would -# replace the user's bar layout with the stock one -# hypr/ the stock Hyprland config; the personal hypr/ below is authoritative -# and is installed to the same place -for entry in "$repo_dir"/config/*; do - entry_name="$(basename "$entry")" - [[ $entry_name == blob || $entry_name == hypr ]] && continue - if [[ -d $entry ]]; then - install_tree "$entry" "$config_dir/$entry_name" "config/$entry_name" - else - install_file "$entry" "$config_dir/$entry_name" "config/$entry_name" - fi -done -install_tree "$repo_dir/hooks" "$config_dir/blob/hooks" "hooks" -install_tree "$repo_dir/branding" "$config_dir/blob/branding" "branding" -install_file "$repo_dir/shell.json" "$config_dir/blob/shell.json" "shell.json" -install_file "$repo_dir/session/uwsm/default" "$config_dir/uwsm/default" "uwsm/default" -install_file "$repo_dir/session/uwsm/env.d/10-blob" "$config_dir/uwsm/env.d/10-blob" "uwsm/env.d/10-blob" -install_file "$repo_dir/fonts/omarchy.ttf" "$font_dir/omarchy.ttf" "fonts/omarchy.ttf" -install_session_entry +install_shipped_config +install_user_units +install_systemd_dropins +install_zen_config -zen_profile="$(find "$config_dir/zen" -maxdepth 1 -type d -name '*.Default (release)*' 2>/dev/null | head -1)" -if [[ -n $zen_profile ]]; then - install_file "$repo_dir/zen/userChrome.css" "$zen_profile/chrome/userChrome.css" "zen/userChrome.css" -else - say "SKIP zen/userChrome.css (no Zen profile found)" -fi +enable_system_services +mask_system_services +enable_user_units + +install_login_config +install_session_entry +install_autologin +enable_display_manager say "" if [[ $check == true ]]; then @@ -321,6 +81,7 @@ if [[ $check == true ]]; then (( changes == 0 )) || exit 1 else say "$changes item(s) changed." + say "Set a theme with 'blob theme set ' once the session is up." say "" - say "Next: log out and pick the Blob session, then run 'blob theme set '." + launch_desktop fi diff --git a/install/helpers/as-root.sh b/install/helpers/as-root.sh new file mode 100644 index 0000000..005ae35 --- /dev/null +++ b/install/helpers/as-root.sh @@ -0,0 +1,7 @@ +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} diff --git a/install/helpers/lib.sh b/install/helpers/lib.sh new file mode 100644 index 0000000..4a6d9df --- /dev/null +++ b/install/helpers/lib.sh @@ -0,0 +1,105 @@ +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/as-root.sh" + +say() { + printf '%s\n' "$1" +} + +note_change() { + changes=$((changes + 1)) +} + +system_unit_exists() { + systemctl cat -- "$1" &>/dev/null +} + +user_manager_available() { + systemctl --user show-environment &>/dev/null +} + +system_unit_state() { + systemctl is-enabled -- "$1" 2>/dev/null || true +} + +user_unit_state() { + systemctl --user is-enabled -- "$1" 2>/dev/null || true +} + +# Copy a file only when the destination is missing or matches what we last +# wrote. A destination that differs is a local edit, reported and kept unless +# --force says otherwise. +install_file() { + local source="$1" dest="$2" label="$3" + + if [[ ! -e $source ]]; then + say "SKIP $label (missing in repo)" + return 0 + fi + + if [[ -e $dest ]] && cmp -s "$source" "$dest"; then + say "ok $label" + return 0 + fi + + note_change + + if [[ -e $dest ]] && [[ $force == false ]]; then + say "DIFF $label (local changes kept; --force to overwrite)" + return 0 + fi + + if [[ $check == true ]]; then + say "would write $label" + return 0 + fi + + mkdir -p "$(dirname "$dest")" + [[ -e $dest ]] && cp "$dest" "$dest.bak" + cp "$source" "$dest" + say "write $label" +} + +install_tree() { + local source="$1" dest="$2" label="$3" + + if [[ ! -d $source ]]; then + say "SKIP $label (missing in repo)" + return 0 + fi + + local relative + while IFS= read -r relative; do + install_file "$source/$relative" "$dest/$relative" "$label/$relative" + done < <(cd "$source" && find . -type f -printf '%P\n' | sort) +} + +# Same reporting as install_file for a destination outside $HOME. The mode is +# explicit because these land in /etc and /usr/local, and no .bak is kept: the +# only writer of these paths is the installer. +install_root_file() { + local source="$1" dest="$2" label="$3" mode="${4:-0644}" + + if [[ ! -e $source ]]; then + say "SKIP $label (missing in repo)" + return 0 + fi + + if [[ -e $dest ]] && cmp -s "$source" "$dest"; then + say "ok $label" + return 0 + fi + + note_change + + if [[ -e $dest ]] && [[ $force == false ]]; then + say "DIFF $label (local changes kept; --force to overwrite)" + return 0 + fi + + if [[ $check == true ]]; then + say "would write $label (needs sudo)" + return 0 + fi + + as_root install -Dm "$mode" "$source" "$dest" + say "write $label" +} diff --git a/install/steps/config.sh b/install/steps/config.sh new file mode 100644 index 0000000..1969151 --- /dev/null +++ b/install/steps/config.sh @@ -0,0 +1,71 @@ +# BLOB_PATH is a symlink to this checkout rather than a copy, so bin/, shell/, +# themes/ and default/ are always the working tree. Every path the shell +# resolves as $BLOB_PATH/... therefore needs no install step at all. +link_blob_path() { + local current="" + [[ -L $blob_path ]] && current="$(readlink -f "$blob_path")" + + if [[ $current == "$repo_dir" ]]; then + say "ok BLOB_PATH -> $repo_dir" + return 0 + fi + + if [[ -e $blob_path && ! -L $blob_path ]]; then + say "WARN $blob_path exists and is not a symlink; move it aside first" + note_change + return 0 + fi + + note_change + if [[ $check == true ]]; then + say "would link BLOB_PATH -> $repo_dir" + return 0 + fi + + ln -sfn "$repo_dir" "$blob_path" + say "link BLOB_PATH -> $repo_dir" +} + +# config/ is the shipped-defaults directory. Everything in it is reachable as +# $BLOB_PATH/config/... so `blob-refresh-config` can reset a file, and most of it +# is also the right thing to deploy on a fresh install. Two subdirectories are +# reference-only and must not be copied into ~/.config: +# +# blob/ the shell defaults; copying them over ~/.config/blob/shell.json would +# replace the user's bar layout with the stock one +# hypr/ the stock Hyprland config; the personal hypr/ below is authoritative +# and is installed to the same place +install_shipped_config() { + local entry entry_name + + install_tree "$repo_dir/hypr" "$config_dir/hypr" "hypr" + + for entry in "$repo_dir"/config/*; do + entry_name="$(basename "$entry")" + [[ $entry_name == blob || $entry_name == hypr ]] && continue + if [[ -d $entry ]]; then + install_tree "$entry" "$config_dir/$entry_name" "config/$entry_name" + else + install_file "$entry" "$config_dir/$entry_name" "config/$entry_name" + fi + done + + install_tree "$repo_dir/hooks" "$config_dir/blob/hooks" "hooks" + install_tree "$repo_dir/branding" "$config_dir/blob/branding" "branding" + install_file "$repo_dir/shell.json" "$config_dir/blob/shell.json" "shell.json" + install_file "$repo_dir/session/uwsm/default" "$config_dir/uwsm/default" "uwsm/default" + install_file "$repo_dir/session/uwsm/env.d/10-blob" "$config_dir/uwsm/env.d/10-blob" "uwsm/env.d/10-blob" + install_file "$repo_dir/fonts/omarchy.ttf" "$font_dir/omarchy.ttf" "fonts/omarchy.ttf" +} + +install_zen_config() { + local profile + profile="$(find "$config_dir/zen" -maxdepth 1 -type d -name '*.Default (release)*' 2>/dev/null | head -1)" + + if [[ -z $profile ]]; then + say "SKIP zen/userChrome.css (no Zen profile found)" + return 0 + fi + + install_file "$repo_dir/zen/userChrome.css" "$profile/chrome/userChrome.css" "zen/userChrome.css" +} diff --git a/install/steps/directories.sh b/install/steps/directories.sh new file mode 100644 index 0000000..0a393a1 --- /dev/null +++ b/install/steps/directories.sh @@ -0,0 +1,44 @@ +directories=( + "$HOME/.config/blob/hooks" + "$HOME/.config/blob/extensions" + "$HOME/.config/blob/plugins" + "$HOME/.config/blob/themed" + "$HOME/.config/blob/themes" + "$HOME/.config/blob/branding" + "$HOME/.config/hypr" + "$HOME/.config/uwsm/env.d" + "$HOME/.config/systemd/user/app.slice.d" + "$HOME/.local/state/blob/toggles/hypr" + "$HOME/.local/state/blob/indicators" + "$HOME/.local/state/blob/notifications/history" + "$HOME/.local/state/blob/notifications/images" + "$HOME/.local/state/blob/backgrounds" + "$HOME/.local/state/blob/current" + "$HOME/.local/share/fonts" + "$HOME/.local/share/applications" + "$HOME/.local/share/icons/hicolor/256x256/apps" + "$HOME/wallpapers" +) + +create_directories() { + local missing=() + local dir + for dir in "${directories[@]}"; do + [[ -d $dir ]] || missing+=("$dir") + done + + if (( ${#missing[@]} == 0 )); then + say "ok directories" + return 0 + fi + + note_change + if [[ $check == true ]]; then + say "would create ${#missing[@]} directory(ies):" + printf ' %s\n' "${missing[@]}" + return 0 + fi + + mkdir -p "${missing[@]}" + say "mkdir ${#missing[@]} directory(ies)" +} diff --git a/install/steps/login.sh b/install/steps/login.sh new file mode 100644 index 0000000..7fd3f21 --- /dev/null +++ b/install/steps/login.sh @@ -0,0 +1,102 @@ +session_entry_dir=/usr/local/share/wayland-sessions +greeter_compositor_config=/etc/sddm/hyprland-greeter.lua + +# SDDM's stock Wayland greeter runs on weston, which Blob does not install. +# Point it at Hyprland with a greeter-only config instead. +install_login_config() { + install_root_file "$repo_dir/default/sddm/hyprland-greeter.lua" \ + "$greeter_compositor_config" "sddm/hyprland-greeter.lua" + install_root_file "$repo_dir/default/sddm/10-blob.conf" \ + /etc/sddm.conf.d/10-blob.conf "sddm.conf.d/10-blob.conf" +} + +install_session_entry() { + install_root_file "$repo_dir/session/blob.desktop" \ + "$session_entry_dir/blob.desktop" "wayland session entry" +} + +install_autologin() { + if [[ $autologin == false ]]; then + return 0 + fi + + local generated + generated="$(mktemp)" + printf '[Autologin]\nUser=%s\nSession=blob.desktop\n' "$USER" >"$generated" + install_root_file "$generated" \ + /etc/sddm.conf.d/99-blob-autologin.conf "sddm.conf.d/99-blob-autologin.conf" + rm -f "$generated" +} + +# Enabling sddm is not enough on a machine installed without a display manager: +# its default target is multi-user.target, which never pulls in graphical.target. +enable_display_manager() { + if ! system_unit_exists sddm.service; then + say "WARN sddm.service not found; the desktop cannot start at boot" + return 0 + fi + + local enable_needed=false + case "$(system_unit_state sddm.service)" in + enabled | enabled-runtime) ;; + *) enable_needed=true ;; + esac + + local default_target + default_target="$(systemctl get-default 2>/dev/null || true)" + + local target_needed=false + [[ $default_target == graphical.target ]] || target_needed=true + + if [[ $enable_needed == false && $target_needed == false ]]; then + say "ok display manager" + return 0 + fi + + note_change + if [[ $check == true ]]; then + [[ $enable_needed == true ]] && say "would enable sddm.service" + [[ $target_needed == true ]] && say "would set the default target to graphical.target" + return 0 + fi + + if [[ $enable_needed == true ]]; then + as_root systemctl enable sddm.service + say "enable sddm.service" + fi + + if [[ $target_needed == true ]]; then + as_root systemctl set-default graphical.target + say "set default target to graphical.target" + fi +} + +# The installer is usually run from a bare console on a fresh machine, where +# enabling the display manager alone leaves the user looking at a shell prompt. +launch_desktop() { + if [[ $check == true ]]; then + return 0 + fi + + if [[ $no_launch == true ]]; then + say "Run 'sudo systemctl start sddm' or reboot to reach the desktop." + return 0 + fi + + if ! system_unit_exists sddm.service; then + return 0 + fi + + if [[ -n ${WAYLAND_DISPLAY:-} || -n ${DISPLAY:-} ]]; then + say "Log out and pick the Blob session at the login screen." + return 0 + fi + + if systemctl is-active --quiet sddm.service; then + say "The login screen is already running." + return 0 + fi + + say "Starting the login screen. Pick the Blob session to log in." + as_root systemctl start sddm.service +} diff --git a/install/steps/packages.sh b/install/steps/packages.sh new file mode 100644 index 0000000..221baa4 --- /dev/null +++ b/install/steps/packages.sh @@ -0,0 +1,86 @@ +read_package_list() { + [[ -f $1 ]] || return 0 + grep -vE '^[[:space:]]*#|^[[:space:]]*$' "$1" +} + +# A list entry may be "wanted|already-fine", which asks for the first name but +# treats either as satisfying the requirement. That is how the AUR replacement +# for a package still shipped by another repository avoids a file conflict. +missing_packages() { + local entry wanted alternative + for entry in $(read_package_list "$1"); do + wanted=${entry%%|*} + alternative=${entry#*|} + pacman -Q "$wanted" &>/dev/null && continue + [[ $alternative != "$entry" ]] && pacman -Q "$alternative" &>/dev/null && continue + printf '%s\n' "$wanted" + done +} + +install_packages() { + if [[ $skip_packages == true ]]; then + say "skip packages (--skip-packages)" + return 0 + fi + + local -a repo_missing aur_missing + mapfile -t repo_missing < <(missing_packages "$repo_dir/packages/blob.packages") + mapfile -t aur_missing < <(missing_packages "$repo_dir/packages/blob-aur.packages") + + if (( ${#repo_missing[@]} == 0 && ${#aur_missing[@]} == 0 )); then + say "ok packages" + return 0 + fi + + note_change + if [[ $check == true ]]; then + (( ${#repo_missing[@]} )) && say "would install ${#repo_missing[@]} repo package(s): ${repo_missing[*]}" + (( ${#aur_missing[@]} )) && say "would install ${#aur_missing[@]} AUR package(s): ${aur_missing[*]}" + return 0 + fi + + blob_sudo_keepalive + + if (( ${#repo_missing[@]} )); then + say "install ${#repo_missing[@]} repo package(s)" + as_root pacman -S --needed --noconfirm "${repo_missing[@]}" + fi + + if (( ${#aur_missing[@]} )); then + ensure_aur_helper || { + say "WARN no AUR helper; skipping ${#aur_missing[@]} AUR package(s): ${aur_missing[*]}" + return 0 + } + say "install ${#aur_missing[@]} AUR package(s)" + yay -S --needed --noconfirm "${aur_missing[@]}" + fi +} + +# A fresh Arch install has no AUR helper, and yay is not in the official +# repositories, so it is built from the AUR once with makepkg. Everything after +# this point can then use yay normally. +ensure_aur_helper() { + command -v yay &>/dev/null && return 0 + + say "bootstrap yay from the AUR" + as_root pacman -S --needed --noconfirm base-devel git + + local build_dir + build_dir="$(mktemp -d)" + if ! git clone --depth 1 https://aur.archlinux.org/yay-bin.git "$build_dir/yay-bin"; then + rm -rf "$build_dir" + return 1 + fi + + ( cd "$build_dir/yay-bin" && makepkg -si --noconfirm ) || { + rm -rf "$build_dir" + return 1 + } + + rm -rf "$build_dir" + command -v yay &>/dev/null +} + +blob_sudo_keepalive() { + sudo -v 2>/dev/null || true +} diff --git a/install/steps/services.sh b/install/steps/services.sh new file mode 100644 index 0000000..8059209 --- /dev/null +++ b/install/steps/services.sh @@ -0,0 +1,144 @@ +system_services=( + NetworkManager.service + systemd-resolved.service + bluetooth.service + cups.service + avahi-daemon.service + docker.socket + power-profiles-daemon.service + systemd-oomd.service +) + +# network-online.target must not hold up graphical.target waiting for DHCP or +# Wi-Fi association. Nothing in the session blocks on the network. +masked_services=( + NetworkManager-wait-online.service +) + +# blob-speaker-tuning.service is deliberately absent: blob-audio-tuning installs +# and enables it itself, only on machines with a tuning profile. +user_services=( + bt-agent.service + blob-sleep-lock.service + blob-fcitx5.service + blob-crash-watch.service +) + +enable_system_services() { + local pending=() + local unit + + for unit in "${system_services[@]}"; do + system_unit_exists "$unit" || continue + case "$(system_unit_state "$unit")" in + enabled | enabled-runtime | static | alias | indirect | masked) continue ;; + esac + pending+=("$unit") + done + + if (( ${#pending[@]} == 0 )); then + say "ok system services" + return 0 + fi + + note_change + if [[ $check == true ]]; then + say "would enable ${#pending[@]} system service(s): ${pending[*]}" + return 0 + fi + + as_root systemctl enable "${pending[@]}" + say "enable ${#pending[@]} system service(s)" +} + +mask_system_services() { + local pending=() + local unit + + for unit in "${masked_services[@]}"; do + system_unit_exists "$unit" || continue + [[ "$(system_unit_state "$unit")" == masked ]] && continue + pending+=("$unit") + done + + if (( ${#pending[@]} == 0 )); then + say "ok masked services" + return 0 + fi + + note_change + if [[ $check == true ]]; then + say "would mask ${#pending[@]} service(s): ${pending[*]}" + return 0 + fi + + as_root systemctl mask "${pending[@]}" + say "mask ${#pending[@]} service(s)" +} + +install_systemd_dropins() { + local systemd_dir="$repo_dir/default/systemd" + + install_root_file "$systemd_dir/faster-shutdown.conf" \ + /etc/systemd/system.conf.d/10-blob.conf "systemd/system.conf.d/10-blob.conf" + install_root_file "$systemd_dir/user@.service.d/faster-shutdown.conf" \ + /etc/systemd/system/user@.service.d/10-blob.conf "systemd/user@.service.d/10-blob.conf" + install_root_file "$systemd_dir/oomd.conf.d/10-blob.conf" \ + /etc/systemd/oomd.conf.d/10-blob.conf "systemd/oomd.conf.d/10-blob.conf" + install_root_file "$systemd_dir/system-sleep/keyboard-backlight" \ + /etc/systemd/system-sleep/keyboard-backlight "systemd/system-sleep/keyboard-backlight" 0755 + install_root_file "$systemd_dir/system-sleep/unmount-fuse" \ + /etc/systemd/system-sleep/unmount-fuse "systemd/system-sleep/unmount-fuse" 0755 + + if system_unit_exists plocate-updatedb.service; then + install_root_file "$systemd_dir/system/plocate-updatedb.service.d/10-blob.conf" \ + /etc/systemd/system/plocate-updatedb.service.d/10-blob.conf \ + "systemd/plocate-updatedb.service.d/10-blob.conf" + else + say "SKIP systemd/plocate-updatedb.service.d/10-blob.conf (plocate not installed)" + fi +} + +install_user_units() { + local unit + + for unit in "${user_services[@]}"; do + install_file "$repo_dir/default/systemd/user/$unit" \ + "$config_dir/systemd/user/$unit" "systemd/user/$unit" + done + + install_file "$repo_dir/default/systemd/user/app.slice.d/10-oomd.conf" \ + "$config_dir/systemd/user/app.slice.d/10-oomd.conf" "systemd/user/app.slice.d/10-oomd.conf" +} + +# Enable only. The units are all WantedBy=graphical-session.target, so they come +# up with the session rather than from a TTY where their conditions cannot pass. +enable_user_units() { + local pending=() + local unit + + if ! user_manager_available; then + say "WARN no user systemd manager; skipping user services" + return 0 + fi + + for unit in "${user_services[@]}"; do + [[ "$(user_unit_state "$unit")" == enabled ]] && continue + pending+=("$unit") + done + + if (( ${#pending[@]} == 0 )); then + say "ok user services" + return 0 + fi + + note_change + if [[ $check == true ]]; then + say "would enable ${#pending[@]} user service(s): ${pending[*]}" + return 0 + fi + + systemctl --user daemon-reload + systemctl --user enable "${pending[@]}" + say "enable ${#pending[@]} user service(s)" +} diff --git a/packages/blob.packages b/packages/blob.packages index 85ac425..115d865 100644 --- a/packages/blob.packages +++ b/packages/blob.packages @@ -41,6 +41,11 @@ bluez bluez-utils bluez-tools +# Input method, for the XCompose sequences blob-fcitx5.service serves +fcitx5 +fcitx5-gtk +fcitx5-qt + # Audio pipewire pipewire-alsa diff --git a/uninstall.sh b/uninstall.sh index 7e034c0..ea25031 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -4,9 +4,29 @@ set -e blob_path="$HOME/.local/share/blob" config_dir="$HOME/.config" -session_entry="/usr/share/wayland-sessions/blob.desktop" +session_entry="/usr/local/share/wayland-sessions/blob.desktop" + +root_files=( + /etc/sddm.conf.d/10-blob.conf + /etc/sddm.conf.d/99-blob-autologin.conf + /etc/sddm/hyprland-greeter.lua + /etc/systemd/system.conf.d/10-blob.conf + /etc/systemd/system/user@.service.d/10-blob.conf + /etc/systemd/oomd.conf.d/10-blob.conf + /etc/systemd/system/plocate-updatedb.service.d/10-blob.conf + /etc/systemd/system-sleep/keyboard-backlight + /etc/systemd/system-sleep/unmount-fuse +) + +user_units=( + bt-agent.service + blob-sleep-lock.service + blob-fcitx5.service + blob-crash-watch.service +) keep_state=false +keep_session=false assume_yes=false usage() { @@ -16,6 +36,7 @@ Usage: ./uninstall.sh [OPTIONS] Removes what install.sh wrote. The checkout itself is never touched. --keep-state Leave ~/.local/state/blob in place + --keep-session Leave the Blob session entry and the login screen config --yes Do not prompt --help Show this message USAGE @@ -25,6 +46,7 @@ USAGE while (( $# > 0 )); do case "$1" in --keep-state) keep_state=true; shift ;; + --keep-session) keep_session=true; shift ;; --yes) assume_yes=true; shift ;; --help) usage ;; *) echo "Unknown option: $1" >&2; usage ;; @@ -41,7 +63,7 @@ restore_or_remove() { if [[ -e $path.bak ]]; then mv "$path.bak" "$path" say "restore $label (from .bak)" - return + return 0 fi if [[ -e $path ]]; then @@ -85,6 +107,28 @@ if [[ $keep_state == false && -d $HOME/.local/state/blob ]]; then say "remove ~/.local/state/blob" fi +for unit in "${user_units[@]}"; do + if [[ "$(systemctl --user is-enabled -- "$unit" 2>/dev/null || true)" == enabled ]]; then + systemctl --user disable --now "$unit" >/dev/null 2>&1 || true + say "disable $unit" + fi + rm -f "$config_dir/systemd/user/$unit" +done +rm -f "$config_dir/systemd/user/app.slice.d/10-oomd.conf" + +if [[ $keep_session == true ]]; then + say "" + say "Done. The Blob session entry and the login screen config were kept." + exit 0 +fi + +for path in "${root_files[@]}"; do + if [[ -e $path ]]; then + sudo rm -f "$path" + say "remove $path" + fi +done + if [[ -e $session_entry ]]; then sudo rm -f "$session_entry" say "remove wayland session entry"