Compare commits

...
31 Commits
Author SHA1 Message Date
SirBlob 747b7ebdb7 Install the boot splash theme and set up GRUB without Limine 2026-09-21 02:10:47 +00:00
SirBlob 5858eec50d Draw the About logo and bar icon as a cup of chai 2026-09-21 02:10:40 +00:00
SirBlob 8d8dee2342 Theme fastfetch from the active palette 2026-09-21 02:10:39 +00:00
SirBlob 5372c6ea29 Centre the quick settings panel under the clock 2026-09-21 02:10:29 +00:00
SirBlob 740586611a Stop alerting when a theme ships no backgrounds 2026-09-21 02:10:29 +00:00
SirBlob 3211350435 Stop the theme menu drawing theme names in the icon cell 2026-09-21 02:10:28 +00:00
SirBlob 933f3a9efd Redesign the flats palette around a proper surface ramp 2026-09-21 02:10:19 +00:00
SirBlob 06446f36f8 Make cd jump to frequently used directories with zoxide 2026-09-21 02:10:18 +00:00
SirBlob 49d2f0e41e Put the prompt directory and the input on one line 2026-09-21 02:10:17 +00:00
SirBlob c0c4eadfe1 Let the screensaver effects draw in color 2026-09-21 02:10:16 +00:00
SirBlob 90c22b4e0c Run every terminal launch through one dispatcher with fallbacks 2026-09-21 02:10:01 +00:00
SirBlob d316308bf5 Install the lock screen PAM stack and replace the missing screensaver effects 2026-09-21 01:07:34 +00:00
SirBlob 64afc07250 Start the starship prompt and build it from the active theme 2026-09-21 01:07:27 +00:00
SirBlob e74c9c5bc1 Stop btop painting the meter track in the selection color 2026-09-21 01:07:27 +00:00
SirBlob a9b9ba6ba4 Bind monospace to a Nerd Font and keep panels clear of the bar 2026-09-21 01:07:16 +00:00
SirBlob deeacbe580 Theme GTK apps from the active palette 2026-09-21 00:49:40 +00:00
SirBlob bfba908ef4 List wallpapers through the symlinked wallpapers folder 2026-09-21 00:49:35 +00:00
SirBlob ef02250a8f Show the update icon only when updates are pending 2026-09-21 00:49:35 +00:00
SirBlob e5c20e0a46 Add the missing terminal launcher behind Super and Enter 2026-09-21 00:49:35 +00:00
SirBlob 737d601bca Pick a renderer from the GPU and fall back to software rendering 2026-09-21 00:49:29 +00:00
SirBlob 00803e59a4 Drop the boot splash and mask plymouth so the console stays reachable 2026-09-20 23:18:50 +00:00
SirBlob 3fbdd754c1 Find systemd units on disk and report the packages the desktop needs 2026-09-20 23:13:32 +00:00
SirBlob 06204a357a Put the blob command on PATH and reapply the default theme when it is missing 2026-09-20 23:07:25 +00:00
SirBlob 2f29cb8bae Install the maintained pywal fork and skip packages already present 2026-09-20 23:02:17 +00:00
SirBlob 52e747fe3e Connect the generated theme files to btop, Neovim, and kitty 2026-09-20 13:31:43 -04:00
SirBlob f00d09eb02 Fix the black screen on first boot and the installer's missing directories 2026-09-20 13:18:03 -04:00
SirBlob 421c58fab0 Override leftover SDDM drop-ins so the greeter cannot land on a black screen 2026-09-20 07:06:23 -04:00
SirBlob 8f124f47c2 Start the desktop after install by enabling the login screen and services 2026-09-20 06:40:41 -04:00
SirBlob f4dd3b7532 Fix package lists that only resolved via the Omarchy repository 2026-09-20 04:27:40 -04:00
SirBlob 9e34929e48 Split display layout into its own widget and fix installer early exit 2026-09-20 04:12:56 -04:00
SirBlob a32254c398 Add a displays widget for monitor layout and per-monitor wallpapers 2026-09-20 04:07:26 -04:00
85 changed files with 3917 additions and 447 deletions
+31 -7
View File
@@ -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,38 @@ 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. Directories | creates the 19 directories the shell and scripts expect, before anything writes into them |
| 2. `BLOB_PATH` | symlinks `~/.local/share/blob` to this checkout |
| 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`, and writes `/etc/profile.d/blob.sh` so a login shell finds the `blob` command |
| 5. First theme | links `~/wallpapers` to the checkout, applies `flats` headless, picks a background, and links the btop and Neovim theme files, so the first login is not a blank desktop |
| 6. User units | installs the session services into `~/.config/systemd/user` and enables them |
| 7. systemd drop-ins | writes the shutdown timeouts, oomd thresholds, plocate prune paths, and sleep hooks under `/etc` |
| 8. System services | enables NetworkManager, resolved, bluetooth, cups, avahi, docker, power-profiles-daemon, and oomd, and masks `NetworkManager-wait-online` |
| 9. Login screen | installs the session entry and the SDDM config, enables `sddm.service`, and sets the default target to `graphical.target` |
| 10. Launch | starts the login screen, so the desktop comes up without a reboot |
Step 9 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 with a Lua config, the only format Hyprland still reads. It also
reports any leftover drop-in that autologs into a session that is not installed,
which is the other thing a black screen with no login prompt usually is.
[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 symlinks, restoring any `.bak` the installer made. The
checkout, the wallpapers, and the themes stay where they are.
## Regenerating docs
+81
View File
@@ -0,0 +1,81 @@
#!/bin/bash
# blob:summary=Assign a wallpaper to one monitor, leaving the others alone
# blob:args=<--list|--clear-all|<output> <image>|<output> --clear>
# blob:examples=blob bg monitor DP-1 ~/wallpapers/astronaut2.jpg
set -e
state_dir="$HOME/.local/state/blob/backgrounds"
mkdir -p "$state_dir"
usage() {
cat <<USAGE
Usage: blob-bg-monitor <output> <image> assign a wallpaper to one monitor
blob-bg-monitor <output> --clear fall back to the global wallpaper
blob-bg-monitor --list show current assignments
blob-bg-monitor --clear-all drop every assignment
Monitors with no assignment show the global wallpaper set by blob-bg-set.
Output names are what 'hyprctl monitors' reports, such as DP-1 or eDP-1.
USAGE
}
notify_shell() {
blob-shell -q background refresh
}
known_output() {
hyprctl monitors all -j 2>/dev/null | jq -e --arg name "$1" 'any(.[]; .name == $name)' >/dev/null
}
case "${1-}" in
--list)
found=false
for link in "$state_dir"/*; do
[[ -e $link ]] || continue
found=true
printf '%-12s %s\n' "$(basename "$link")" "$(readlink -f "$link")"
done
[[ $found == true ]] || echo "No per-monitor wallpapers assigned."
exit 0
;;
--clear-all)
rm -f "$state_dir"/*
notify_shell
echo "Cleared every per-monitor wallpaper."
exit 0
;;
""|--help|-h)
usage
exit 0
;;
esac
output="$1"
image="${2-}"
if [[ -z $image ]]; then
usage >&2
exit 1
fi
if ! known_output "$output"; then
echo "Warning: '$output' is not a connected monitor right now." >&2
fi
if [[ $image == --clear ]]; then
rm -f "$state_dir/$output"
notify_shell
echo "Cleared $output; it follows the global wallpaper again."
exit 0
fi
if [[ ! -f $image ]]; then
echo "Not a file: $image" >&2
exit 1
fi
ln -sfn "$(realpath "$image")" "$state_dir/$output"
notify_shell
echo "Set $output to $(basename "$image")"
+128 -10
View File
@@ -3,7 +3,9 @@
# blob-boot - boot splash and bootloader management.
#
# blob-boot [image] Apply a Plymouth boot splash image (default action)
# blob-boot grub Migrate from Limine back to GRUB, detecting every OS
# blob-boot grub Install GRUB and detect every other OS on the machine.
# Migrates off Limine when it is there; on a machine
# that never had it, only GRUB is installed
# --purge removes Limine in the same run instead of
# keeping it for one fallback boot
# blob-boot detect Rescan for other operating systems, rebuild the menu
@@ -16,8 +18,12 @@
set -euo pipefail
DEFAULT_IMAGE="$HOME/Documents/dotfiles/branding/boot_flash.png"
PLYMOUTH_LOGO="/usr/share/plymouth/themes/blob/logo.png"
BLOB_PATH="${BLOB_PATH:-$HOME/.local/share/blob}"
DEFAULT_IMAGE="$BLOB_PATH/branding/boot_flash.png"
THEME_SOURCE="$BLOB_PATH/default/plymouth"
THEME_DIR="/usr/share/plymouth/themes/blob"
PLYMOUTH_LOGO="$THEME_DIR/logo.png"
MKINITCPIO_DROPIN="/etc/mkinitcpio.conf.d/blob-plymouth.conf"
log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
info() { printf ' %s\n' "$*"; }
@@ -43,17 +49,24 @@ apply_splash() {
fi
[ -f "$image_path" ] || die "File '$image_path' does not exist."
[ -d "$(dirname "$PLYMOUTH_LOGO")" ] || die "Plymouth blob theme is not installed."
command -v plymouth-set-default-theme >/dev/null \
|| die "The 'plymouth' package is not installed. Install it with: sudo pacman -S plymouth"
log "Applying boot splash image: $image_path"
require_sudo
sudo cp "$image_path" "$PLYMOUTH_LOGO"
sudo chmod 644 "$PLYMOUTH_LOGO"
install_theme "$image_path"
install_initramfs_hook
enable_plymouth_units
log "Setting the default Plymouth theme"
sudo plymouth-set-default-theme blob
log "Rebuilding initramfs"
rebuild_initramfs
report_kernel_cmdline
# Under GRUB the menu references the initramfs by path, so it only needs
# regenerating if the file set changed - but it is cheap and keeps the menu
# honest after a kernel or os-prober change.
@@ -65,6 +78,80 @@ apply_splash() {
ok "Boot splash successfully updated!"
}
install_theme() {
local image_path=$1
[ -d "$THEME_SOURCE" ] || die "Theme source '$THEME_SOURCE' is missing from the checkout."
sudo install -d -m 0755 "$THEME_DIR"
sudo install -m 0644 "$THEME_SOURCE/blob.plymouth" "$THEME_DIR/blob.plymouth"
sudo install -m 0644 "$THEME_SOURCE/blob.script" "$THEME_DIR/blob.script"
sudo install -m 0644 "$image_path" "$PLYMOUTH_LOGO"
info "installed the blob theme to $THEME_DIR"
}
# A drop-in rather than an edit of HOOKS: mkinitcpio.conf.d is sourced after
# the main config, so the hook can be inserted without rewriting a line the
# user or another tool owns. plymouth has to come after udev, which starts the
# devices it draws on.
install_initramfs_hook() {
sudo install -d -m 0755 "$(dirname "$MKINITCPIO_DROPIN")"
sudo tee "$MKINITCPIO_DROPIN" >/dev/null <<'DROPIN'
# Written by blob-boot. Adds the plymouth hook after udev, once.
if [[ " ${HOOKS[*]} " != *" plymouth "* ]]; then
_hooks=()
for _hook in "${HOOKS[@]}"; do
_hooks+=("$_hook")
[[ $_hook == "udev" || $_hook == "systemd" ]] && _hooks+=("plymouth")
done
HOOKS=("${_hooks[@]}")
unset _hooks _hook
fi
DROPIN
info "wrote $MKINITCPIO_DROPIN"
}
# install.sh masks these so an unconfigured plymouth cannot hold the console and
# leave the machine with no greeter and no TTY. A configured one needs them: if
# plymouth-quit-wait stays masked the splash never hands the display back.
enable_plymouth_units() {
local unit
for unit in plymouth-start.service plymouth-quit-wait.service; do
if [ "$(systemctl is-enabled "$unit" 2>/dev/null)" = "masked" ]; then
sudo systemctl unmask "$unit" >/dev/null
info "unmasked $unit"
fi
done
}
# plymouth only draws when the kernel was told to be quiet and to splash. The
# bootloader owns that line, so report rather than rewrite anything but GRUB's.
report_kernel_cmdline() {
local params="quiet splash vt.global_cursor_default=0"
if grep -qw splash /proc/cmdline; then
info "kernel command line already carries 'splash'"
return 0
fi
log "One step left: the kernel command line"
if using_grub; then
warn "GRUB is in charge. Add to GRUB_CMDLINE_LINUX_DEFAULT in /etc/default/grub:"
warn " $params"
warn "then: sudo grub-mkconfig -o /boot/grub/grub.cfg"
elif [ -f /boot/limine.conf ]; then
warn "Limine is in charge. Add to the cmdline in /boot/limine.conf:"
warn " $params"
elif [ -d /boot/loader/entries ]; then
warn "systemd-boot is in charge. Add to the options line in /boot/loader/entries/*.conf:"
warn " $params"
else
warn "Add these to your bootloader's kernel command line:"
warn " $params"
fi
warn "Without them the splash is drawn over by kernel messages."
}
# Call mkinitcpio directly: limine-mkinitcpio-hook installs a wrapper at
# /usr/local/bin/mkinitcpio that stops to ask whether to rebuild Limine
# entries, which is useless in a script. While Limine is still the active
@@ -82,6 +169,26 @@ using_grub() {
[ -f /boot/grub/grub.cfg ] && [ -f /boot/EFI/GRUB/grubx64.efi ]
}
# Everything Limine-specific below is skipped on a machine that never had it:
# reclaiming its space, removing its hooks, and rewriting the mkinitcpio preset
# its hooks needed. Rewriting that preset elsewhere would drop the fallback
# initramfs for no reason.
limine_present() {
pacman -Qq limine &>/dev/null || [ -f /boot/limine.conf ] || [ -d /boot/EFI/limine ]
}
install_grub_packages() {
local missing=()
command -v grub-install >/dev/null || missing+=(grub)
command -v os-prober >/dev/null || missing+=(os-prober)
(( ${#missing[@]} == 0 )) && return 0
log "Installing ${missing[*]}"
sudo pacman -S --needed --noconfirm "${missing[@]}" \
|| die "Could not install: ${missing[*]}"
}
# --------------------------------------------------------------------------
# Limine -> GRUB migration
# --------------------------------------------------------------------------
@@ -92,10 +199,9 @@ migrate_to_grub() {
[ -d /sys/firmware/efi ] || die "Not booted in UEFI mode; this script assumes UEFI."
mountpoint -q /boot || die "/boot is not mounted."
command -v grub-install >/dev/null || die "The 'grub' package is not installed."
command -v os-prober >/dev/null || die "The 'os-prober' package is not installed."
require_sudo
install_grub_packages
local machine_id esp_uuid backup
machine_id=$(</etc/machine-id)
@@ -111,8 +217,14 @@ migrate_to_grub() {
sudo efibootmgr -v | sudo tee "$backup/efibootmgr-before.txt" >/dev/null 2>&1 || true
info "saved."
reclaim_esp_space "$machine_id" "$backup"
restore_standard_initramfs
if limine_present; then
reclaim_esp_space "$machine_id" "$backup"
restore_standard_initramfs
else
info "Limine is not installed; leaving the initramfs layout alone"
log "Building the initramfs"
rebuild_initramfs
fi
configure_grub_scripts
install_grub "$esp_uuid"
fix_failing_boot_units
@@ -529,6 +641,12 @@ set_boot_order() {
# Delete every trace of Limine and hand its EFI fallback path to GRUB.
purge_limine() {
if ! limine_present; then
log "Claiming the removable EFI fallback path for GRUB"
sudo grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=GRUB --removable --recheck
return 0
fi
log "Removing Limine"
drop_limine_pkgs limine limine-mkinitcpio-hook limine-snapper-sync
+68
View File
@@ -0,0 +1,68 @@
#!/bin/bash
# blob:summary=Apply a monitor layout at runtime without touching monitors.lua
# blob:args=<list|apply <keyword>...|reset>
# blob:examples=blob display arrange apply 'DP-1,1920x1080@60,0x0,1'
set -e
action="${1-}"
shift || true
usage() {
cat <<USAGE
Usage: blob-display-arrange <command>
list print the current layout as monitor keywords
apply <keyword>... apply one or more monitor keywords now
reset reload the Hyprland config, restoring monitors.lua
A keyword is Hyprland's own monitor form:
<name>,<mode>,<x>x<y>,<scale>[,transform,<n>][,mirror,<name>]
<name>,disable
Changes made with apply are runtime only. They last until the config is
reloaded or the session ends; monitors.lua is never written.
USAGE
}
case "$action" in
list)
hyprctl monitors all -j | jq -r '
.[]
| if .disabled then "\(.name),disable"
else
"\(.name),\(.width)x\(.height)@\((.refreshRate * 100 | round) / 100),\(.x)x\(.y),\(.scale)"
+ (if .transform != 0 then ",transform,\(.transform)" else "" end)
+ (if .mirrorOf != "none" then ",mirror,\(.mirrorOf)" else "" end)
end
'
;;
apply)
if (( $# == 0 )); then
echo "apply needs at least one monitor keyword" >&2
exit 1
fi
for keyword in "$@"; do
if [[ $keyword != *,* ]]; then
echo "Not a monitor keyword: $keyword" >&2
exit 1
fi
hyprctl keyword monitor "$keyword" >/dev/null
echo "applied $keyword"
done
;;
reset)
hyprctl reload >/dev/null
echo "reloaded; monitors.lua is authoritative again"
;;
""|--help|-h)
usage
;;
*)
echo "Unknown command: $action" >&2
usage >&2
exit 1
;;
esac
+28
View File
@@ -0,0 +1,28 @@
#!/bin/bash
# blob:summary=Detect whether a DRM device with an accelerated driver is present.
drm_path="${BLOB_DRM_PATH:-/sys/class/drm}"
accelerated_drivers=(
amdgpu
i915
nouveau
nvidia
nvidia-drm
radeon
xe
)
shopt -s nullglob
for card in "$drm_path"/card[0-9]*; do
[[ -L $card/device/driver ]] || continue
driver=$(basename "$(readlink -f "$card/device/driver")")
for accelerated in "${accelerated_drivers[@]}"; do
[[ $driver == "$accelerated" ]] && exit 0
done
done
exit 1
+4 -2
View File
@@ -12,9 +12,11 @@ LOGO_FILE="$HOME/.config/blob/branding/about.txt"
FIT_FILE="$HOME/.local/state/blob/windows/about.fit"
# A user-level fastfetch config can relocate or restyle the logo in ways this
# measurement cannot see, so leave sizing to the float rule in that case.
# measurement cannot see, so leave sizing to the float rule in that case. Blob's
# own config is the symlink the installer makes into the current theme, and the
# measurements below are written against it.
custom_fastfetch_config() {
[[ -f $HOME/.config/fastfetch/config.jsonc ]]
[[ -f $HOME/.config/fastfetch/config.jsonc && ! -L $HOME/.config/fastfetch/config.jsonc ]]
}
# wc -L counts display columns only in a UTF-8 locale. A session that never set
+1 -1
View File
@@ -10,4 +10,4 @@ source blob-restart-gum
cmd="$*"
presentation_script="blob-show-logo; $cmd; if (( \$? != 130 )); then blob-show-done; fi"
exec setsid uwsm-app -- xdg-terminal-exec --app-id=org.blob.terminal --title=Blob -e bash -c "$presentation_script"
exec setsid uwsm-app -- blob-terminal-exec --app-id=org.blob.terminal --title=Blob -e bash -c "$presentation_script"
+3 -2
View File
@@ -2,7 +2,8 @@
# blob:summary=Launch the Blob screensaver in the default terminal on the system with the correct font configuration.
if blob-cmd-missing ttfx; then
if ! blob-screensaver-effect >/dev/null; then
blob-notify-send -g ✋ "Screensaver needs ttfx or tte" "Install python-terminaltexteffects."
exit 1
fi
@@ -15,7 +16,7 @@ if blob-toggle-enabled screensaver-off && [[ $1 != "force" ]]; then
fi
focused=$(blob-hypr-monitor-focused)
terminal=$(xdg-terminal-exec --print-id)
terminal=$(blob-terminal-exec --print-id)
case $terminal in
*Alacritty* | *ghostty* | *foot* | *kitty*) ;;
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# blob:summary=Launch the default terminal
# blob:args=[command [args...]]
exec setsid uwsm-app -- blob-terminal-exec "$@"
+1 -1
View File
@@ -10,4 +10,4 @@ else
APP_ID="org.blob.$(basename $1)"
fi
exec setsid uwsm-app -- xdg-terminal-exec --app-id=$APP_ID -e "$1" "${@:2}"
exec setsid uwsm-app -- blob-terminal-exec --app-id=$APP_ID -e "$1" "${@:2}"
+1 -1
View File
@@ -67,7 +67,7 @@ desktop_name="${desktop_file##*/}"
desktop_name="${desktop_name%.desktop}"
exec_line="$(sed -n 's/^Exec=//p' "$desktop_file" | head -1)"
if [[ $exec_line =~ (^|[[:space:]])(\$TERMINAL|xdg-terminal-exec)[[:space:]].*-e([[:space:]]|$) ]]; then
if [[ $exec_line =~ (^|[[:space:]])(\$TERMINAL|xdg-terminal-exec|blob-terminal-exec)[[:space:]].*-e([[:space:]]|$) ]]; then
BLOB_REMOVE_NOTIFY=false blob-tui-remove "$desktop_name"
exit 0
fi
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# blob:summary=Verify every package list entry resolves, ignoring extra repositories
# blob:hidden=true
set -e
bin_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_dir="$(dirname "$bin_dir")"
official_list="$repo_dir/packages/blob.packages"
aur_list="$repo_dir/packages/blob-aur.packages"
failures=0
entries() {
grep -vE '^[[:space:]]*#|^[[:space:]]*$' "$1" 2>/dev/null || true
}
# pacman -Si and yay -Si both search every configured repository, so a package
# that exists only in a third-party repo passes them and then fails on a machine
# that does not have that repo. These checks name the repositories explicitly.
official_names="$(pacman -Sl core extra multilib 2>/dev/null | awk '{print $2}' | sort -u)"
echo "Checking $official_list against core, extra, multilib"
for entry in $(entries "$official_list"); do
name=${entry%%|*}
if ! printf '%s\n' "$official_names" | grep -qx "$name"; then
echo " NOT IN OFFICIAL REPOS: $name"
failures=$((failures + 1))
fi
done
echo "Checking $aur_list against the AUR"
for entry in $(entries "$aur_list"); do
name=${entry%%|*}
if printf '%s\n' "$official_names" | grep -qx "$name"; then
echo " IN OFFICIAL REPOS, MOVE IT: $name"
failures=$((failures + 1))
continue
fi
if ! yay -Ssa "$name" 2>/dev/null | grep -qE "^aur/$name "; then
echo " NOT IN AUR: $name"
failures=$((failures + 1))
fi
done
echo
if (( failures == 0 )); then
echo "All entries resolve."
exit 0
fi
echo "$failures problem(s)."
exit 1
+28 -5
View File
@@ -2,13 +2,37 @@
# blob:summary=Run the Blob screensaver using random effects from TTE.
effect_command=$(blob-screensaver-effect) || exit 1
effect_args() {
local input="$HOME/.config/blob/branding/screensaver.txt"
if [[ $effect_command == "ttfx" ]]; then
printf '%s\n' -i "$input" --frame-rate 120 --canvas-width 0 --canvas-height 0 \
--reuse-canvas --anchor-canvas c --anchor-text c --random-effect --no-eol \
--no-restore-cursor
return 0
fi
local effects=(
beams binarypath blackhole bouncyballs bubbles burn colorshift crumble
decrypt errorcorrect expand fireworks middleout orbittingvolley overflow
pour print rain randomsequence rings scattered slice slide spotlights
spray swarm sweep synthgrid unstable vhstape waves wipe
)
printf '%s\n' -i "$input" --frame-rate 120 --canvas-width 0 --canvas-height 0 \
--anchor-canvas c --anchor-text c \
"${effects[RANDOM % ${#effects[@]}]}"
}
screensaver_in_focus() {
hyprctl activewindow -j | jq -e '.class == "org.blob.screensaver"' >/dev/null 2>&1
}
exit_screensaver() {
hyprctl eval 'hl.config({ cursor = { invisible = false } })' &>/dev/null || hyprctl keyword cursor:invisible false &>/dev/null || true
pkill -x ttfx 2>/dev/null
pkill -x "$effect_command" 2>/dev/null
pkill -f '[o]rg.blob.screensaver' 2>/dev/null
exit 0
}
@@ -36,11 +60,10 @@ wait_for_terminal_resize() {
wait_for_terminal_resize
while true; do
ttfx -i ~/.config/blob/branding/screensaver.txt \
--frame-rate 120 --canvas-width 0 --canvas-height 0 --reuse-canvas --anchor-canvas c --anchor-text c\
--random-effect --no-eol --no-restore-cursor &
mapfile -t effect_arguments < <(effect_args)
"$effect_command" "${effect_arguments[@]}" &
while pgrep -t "${tty#/dev/}" -x ttfx >/dev/null; do
while pgrep -t "${tty#/dev/}" -x "$effect_command" >/dev/null; do
if read -n1 -t 1 || ! screensaver_in_focus; then
exit_screensaver
fi
+13
View File
@@ -0,0 +1,13 @@
#!/bin/bash
# blob:summary=Print the terminal text effects command the screensaver can use
# blob:hidden=true
for candidate in ttfx tte; do
if command -v "$candidate" &>/dev/null; then
printf '%s\n' "$candidate"
exit 0
fi
done
exit 1
+4 -3
View File
@@ -20,7 +20,8 @@ if pgrep -x "1password" >/dev/null && blob-cmd-present 1password; then
fi
# Avoid running screensaver when locked
pkill -x ttfx 2>/dev/null || true
# ttfx handles SIGTERM asynchronously, so keep its terminal alive until it exits.
timeout 1s pidwait -x ttfx 2>/dev/null || true
effect_command=$(blob-screensaver-effect 2>/dev/null) && {
pkill -x "$effect_command" 2>/dev/null || true
timeout 1s pidwait -x "$effect_command" 2>/dev/null || true
}
pkill -f '[o]rg.blob.screensaver' 2>/dev/null || true
+67
View File
@@ -0,0 +1,67 @@
#!/bin/bash
# blob:summary=Run a command in the default terminal
# blob:args=[--print-id] [--app-id=<app-id>] [--title=<title>] [-e] <command> [args...]
# blob:hidden=true
terminal=""
for candidate in xdg-terminal-exec foot kitty; do
if command -v "$candidate" &>/dev/null; then
terminal=$candidate
break
fi
done
if [[ -z $terminal ]]; then
blob-notify-send "No terminal installed" "Install xdg-terminal-exec, foot or kitty."
exit 1
fi
if [[ $terminal == "xdg-terminal-exec" ]]; then
exec xdg-terminal-exec "$@"
fi
app_id=""
title=""
command_args=()
while (( $# > 0 )); do
case "$1" in
--print-id)
printf '%s\n' "$terminal"
exit 0
;;
--app-id=*)
app_id=${1#--app-id=}
shift
;;
--title=*)
title=${1#--title=}
shift
;;
-e)
shift
command_args+=("$@")
break
;;
*)
command_args+=("$1")
shift
;;
esac
done
launch=("$terminal")
case "$terminal" in
foot)
[[ -n $app_id ]] && launch+=("--app-id=$app_id")
;;
kitty)
[[ -n $app_id ]] && launch+=("--class=$app_id")
;;
esac
[[ -n $title ]] && launch+=("--title=$title")
exec "${launch[@]}" "${command_args[@]}"
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
# blob:summary=Apply the current Blob theme's light or dark preference to GTK apps
# blob:hidden=true
blob-cmd-present gsettings || exit 0
colors_toml=$HOME/.local/state/blob/current/theme/colors.toml
[[ -f $colors_toml ]] || exit 0
mode=$(blob-theme-color --file "$colors_toml" mode)
if [[ $mode == "light" ]]; then
color_scheme="prefer-light"
gtk_theme="Adwaita"
else
color_scheme="prefer-dark"
gtk_theme="Adwaita-dark"
fi
gsettings set org.gnome.desktop.interface color-scheme "$color_scheme" 2>/dev/null || true
gsettings set org.gnome.desktop.interface gtk-theme "$gtk_theme" 2>/dev/null || true
+3 -3
View File
@@ -21,7 +21,7 @@ current_mode() {
}
list_theme_rows() {
printf 'Dynamic (from wallpaper)\tblob-dynamic\n'
printf '\tDynamic (from wallpaper)\tblob-dynamic\n'
{
find "$user_themes_dir" -mindepth 1 -maxdepth 1 -type d 2>/dev/null
@@ -30,8 +30,8 @@ list_theme_rows() {
[ -f "$theme_dir/colors.toml" ] || continue
slug=$(basename "$theme_dir")
[ "$slug" = "blob-dynamic" ] && continue
printf '%s\t%s\n' "$(prettify "$slug")" "$slug"
done | sort -u -t$'\t' -k2,2
printf '\t%s\t%s\n' "$(prettify "$slug")" "$slug"
done | sort -u -t$'\t' -k3,3
}
print_current_colors() {
+1 -1
View File
@@ -108,7 +108,6 @@ set_theme_background() {
local new_background new_background_snapshot
if ! choose_theme_background; then
blob-notify-send "No background was found for theme" -t 2000
shell_ipc shell applyTheme "$colors_payload" "$shell_payload" || true
return
fi
@@ -321,6 +320,7 @@ post_theme_commands=(
blob-restart-btop
blob-theme-foot
blob-theme-browser
blob-theme-gtk
)
if [[ $THEME_HEADLESS != "1" ]]; then
+1
View File
@@ -203,6 +203,7 @@ add_template_value() {
if [[ $value =~ ^#[0-9A-Fa-f]{6}$ ]]; then
rgb=$(hex_to_rgb "$value")
printf 's|{{ %s_rgb }}|%s|g\n' "$key" "$rgb" >>"$sed_script"
printf 's|{{ %s_ansi }}|38;2;%s|g\n' "$key" "${rgb//,/;}" >>"$sed_script"
fi
}
+1 -1
View File
@@ -84,7 +84,7 @@ cat >"$DESKTOP_FILE" <<EOF
Version=1.0
Name=$APP_NAME
Comment=$APP_NAME
Exec=xdg-terminal-exec --app-id=$APP_CLASS -e $APP_EXEC
Exec=blob-terminal-exec --app-id=$APP_CLASS -e $APP_EXEC
Terminal=false
Type=Application
Icon=$ICON_VALUE
+1 -1
View File
@@ -12,7 +12,7 @@ DESKTOP_DIR="$HOME/.local/share/applications/"
if (( $# == 0 )); then
# Find all TUIs
while IFS= read -r -d '' file; do
if grep -qE '^Exec=.*(\$TERMINAL|xdg-terminal-exec).*-e' "$file"; then
if grep -qE '^Exec=.*(\$TERMINAL|xdg-terminal-exec|blob-terminal-exec).*-e' "$file"; then
TUIS+=("$(basename "${file%.desktop}")")
fi
done < <(find "$DESKTOP_DIR" -name '*.desktop' -print0)
+1 -1
View File
@@ -12,7 +12,7 @@ echo "Scanning for TUIs in $APP_DIR..."
tui_desktop_files=()
while IFS= read -r -d '' file; do
if grep -q "Exec=xdg-terminal-exec --app-id=TUI\." "$file" 2>/dev/null; then
if grep -qE "Exec=(xdg|blob)-terminal-exec --app-id=TUI\." "$file" 2>/dev/null; then
tui_desktop_files+=("$file")
fi
done < <(find "$APP_DIR" -maxdepth 1 -name "*.desktop" -print0 2>/dev/null)
+5
View File
@@ -37,6 +37,11 @@ else
fi
fi
if ! command -v wal >/dev/null 2>&1; then
echo "Error: 'wal' is not installed. Install it with: yay -S python-pywal16"
exit 1
fi
echo "Extracting colors using Pywal..."
wal -i "$IMAGE_PATH" -n -q 2> >(grep -v "deprecated in IMv7" >&2)
+25 -26
View File
@@ -1,26 +1,25 @@
@@@@@@@@@@@@
@@@@ @@@@
@@@ @@@
@@@ @@@
@@@ @@
@@ @@ @@ @@
@ @@ @@ @@ @@ @@
@ @@ ##### @ @ @ @@
@ @ ##### @ @ ##### @ @@
@@ @ ### @ @ ###### @ @@
@ @ @ @ ######@@ @@
@@ @@@ @@@ @@ @@@ @
@@ @@
@@ @@
@@ @
@ @@
@@ @@
@@ @@
@ @@
@@ @@
@@ @@
@@@ @@
@@@ @@@
@@@ @@@
@@@@ @@@@
@@@@@@@@@@@@
@@ @@ @@
@@ @@ @@
@@ @@ @@
@@ @@ @@
@@ @@ @@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@ @@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@ @@@@@@
@@ @@ @@
@@ @@ @@
@@ @@ @@
@@ @@ @@
@@ @@@@@@@
@@ @@
@@ @@
@@ @@
@@ @@
@@@@@@@@@@@@@@@@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@@ @@
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<rect x="8.6" y="1.6" width="1.3" height="4.6" rx="0.65" fill="#000000" transform="rotate(-12 9.25 3.9)"/>
<rect x="12.4" y="2.4" width="1.3" height="4.6" rx="0.65" fill="#000000" transform="rotate(12 13.05 4.7)"/>
<path d="M17.2 11 a3.4 3.4 0 0 1 0 6 v-1.7 a1.7 1.7 0 0 0 0 -2.6 z" fill="#000000"/>
<path d="M4 9 h14 l-2.5 9.2 a1.6 1.6 0 0 1 -1.55 1.2 h-5.9 a1.6 1.6 0 0 1 -1.55 -1.2 z" fill="#000000"/>
<rect x="3" y="20.4" width="18" height="2" rx="1" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 612 B

+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
<match target="pattern">
<test name="family" qual="any">
<string>monospace</string>
</test>
<edit name="family" mode="prepend_first" binding="strong">
<string>JetBrainsMono Nerd Font</string>
</edit>
</match>
</fontconfig>
-83
View File
@@ -1,83 +0,0 @@
# vim:ft=kitty
## name: Flats
## upstream: themes/flats/colors.toml
## blurb: Dark theme with a soft blue accent. Hand-translated from the
## Omarchy colors.toml, since macOS has no omarchy-theme-set.
# The basic colors
foreground #cbd2d9
background #0f1012
selection_foreground #0f1012
selection_background #7babe3
# Cursor colors
cursor #cbd2d9
cursor_text_color #0f1012
# URL underline color when hovering with mouse
url_color #7babe3
# Kitty window border colors
active_border_color #7babe3
inactive_border_color #949494
bell_border_color #d58638
# OS Window titlebar colors
wayland_titlebar_color system
macos_titlebar_color system
# Tab bar colors
active_tab_foreground #0f1012
active_tab_background #7babe3
inactive_tab_foreground #cbd2d9
inactive_tab_background #0f1012
tab_bar_background #000000
# Colors for marks (marked text in the terminal)
mark1_foreground #0f1012
mark1_background #7babe3
mark2_foreground #0f1012
mark2_background #38d586
mark3_foreground #0f1012
mark3_background #d58638
# The 16 terminal colors
#
# Note: flats maps the ANSI slots unconventionally — color4 (blue) holds a
# magenta and color6 (cyan) holds the blue, among others. Kept faithful to
# colors.toml so both machines render identically; the comments below name
# the ANSI slot, not the resulting hue.
# black
color0 #000000
color8 #949494
# red
color1 #d53838
color9 #d53838
# green
color2 #38d4d5
color10 #38d4d5
# yellow
color3 #d58638
color11 #d58638
# blue
color4 #d53886
color12 #d53886
# magenta
color5 #38d586
color13 #38d586
# cyan
color6 #3886d5
color14 #3886d5
# white
color7 #d4d4d4
color15 #d4d4d4
+4 -5
View File
@@ -1,11 +1,10 @@
# vim:ft=kitty
#
# kitty config for macOS. The Arch side of these dotfiles gets its terminal
# colors from omarchy-theme-set rewriting the active theme; there is no
# omarchy here, so current-theme.conf is checked in alongside this file and
# generated by hand from themes/<name>/colors.toml.
# Colors come from the active theme, the same way foot.ini takes them: the
# theme pipeline renders default/themed/kitty.conf.tpl into the current-theme
# directory, and blob-restart-terminal signals kitty to reload it.
include current-theme.conf
include ~/.local/state/blob/current/theme/kitty.conf
# Fonts
-32
View File
@@ -1,32 +0,0 @@
add_newline = true
command_timeout = 200
format = "[$directory$git_branch$git_status]($style)$character"
[character]
error_symbol = "[✗](bold cyan)"
success_symbol = "[](bold cyan)"
[directory]
truncation_length = 2
truncation_symbol = "…/"
repo_root_style = "bold cyan"
repo_root_format = "[$repo_root]($repo_root_style)[$path]($style)[$read_only]($read_only_style) "
[git_branch]
format = "[$branch]($style) "
style = "italic cyan"
[git_status]
format = '[$all_status]($style)'
style = "cyan"
ahead = "⇡${count} "
diverged = "⇕⇡${ahead_count}⇣${behind_count} "
behind = "⇣${count} "
conflicted = " "
up_to_date = " "
untracked = "? "
modified = " "
stashed = ""
staged = ""
renamed = ""
deleted = ""
+6 -5
View File
@@ -111,7 +111,8 @@
"system": {"icon":"","label":"System","aliases":["power-menu"]},
// Blob
"blob.wallpaper": {"icon":"󰸉","label":"Wallpaper","action":"blob-wallpaper-set --menu","description":"Pick a wallpaper from ~/wallpapers"},
"blob.wallpaper": {"icon":"󰸉","label":"Wallpaper","action":"blob-shell shell toggle blob.displays","description":"Pick a wallpaper, globally or per monitor"},
"blob.display-layout": {"icon":"󰍹","label":"Display layout","action":"blob-shell shell toggle blob.display-layout","description":"Arrange monitors, set mode, scale, and mirroring"},
"blob.theme": {"icon":"󰢵","label":"Theme","action":"blob-theme-menu","description":"Pick a local or bundled color theme"},
"blob.dynamic": {"icon":"󰁨","label":"Dynamic theme","action":"blob-theme-dynamic","description":"Recolor from the current wallpaper"},
"blob.glass": {"icon":"󱡓","label":"Toggle glass","action":"blob-toggle-glass toggle","checked":"test -f $HOME/.local/state/blob/toggles/hypr/blob-glass.lua"},
@@ -253,8 +254,8 @@
"setup.direct-boot": {"icon":"","label":"Direct Boot","action":"blob-launch-floating blob-setup-direct-boot"},
// Install
"install.package": {"icon":"󰣇","label":"Package","action":"xdg-terminal-exec --app-id=org.blob.terminal blob-pkg-install"},
"install.aur": {"icon":"󰣇","label":"AUR","action":"xdg-terminal-exec --app-id=org.blob.terminal blob-pkg-aur-install"},
"install.package": {"icon":"󰣇","label":"Package","action":"blob-terminal-exec --app-id=org.blob.terminal blob-pkg-install"},
"install.aur": {"icon":"󰣇","label":"AUR","action":"blob-terminal-exec --app-id=org.blob.terminal blob-pkg-aur-install"},
"install.tui": {"icon":"","label":"TUI","action":"blob-launch-floating blob-tui-install"},
"install.style": {"icon":"","label":"Style"},
"install.style.theme": {"icon":"󰸌","label":"Theme","action":"blob-launch-floating blob-theme-install"},
@@ -268,8 +269,8 @@
"install.style.font.iosevka": {"icon":"","label":"Iosevka","action":"blob-install-font Iosevka ttf-iosevka-nerd 'Iosevka Nerd Font Mono'"},
// Remove
"remove.package": {"icon":"󰣇","label":"Package","action":"xdg-terminal-exec --app-id=org.blob.terminal blob-pkg-remove"},
"remove.tui": {"icon":"","label":"TUI","when":"grep -qE '^Exec=.*(\\$TERMINAL|xdg-terminal-exec).*-e' $HOME/.local/share/applications/*.desktop","action":"blob-tui-remove"},
"remove.package": {"icon":"󰣇","label":"Package","action":"blob-terminal-exec --app-id=org.blob.terminal blob-pkg-remove"},
"remove.tui": {"icon":"","label":"TUI","when":"grep -qE '^Exec=.*(\\$TERMINAL|xdg-terminal-exec|blob-terminal-exec).*-e' $HOME/.local/share/applications/*.desktop","action":"blob-tui-remove"},
"remove.theme": {"icon":"󰸌","label":"Theme","action":"blob-theme-remove"},
// Update
+1
View File
@@ -37,6 +37,7 @@ hl.env("PATH", table.concat(kept, ":"))
-- Hardware-specific environment.
require("default.hypr.nvidia")
require("default.hypr.renderer")
hl.config({
xwayland = {
+14
View File
@@ -0,0 +1,14 @@
local paths = require("default.hypr.paths")
local accelerated = paths.blob_path .. "/bin/blob-hw-gpu-accelerated"
if not o.shell_succeeds(o.shell_quote(accelerated)) then
hl.env("LIBGL_ALWAYS_SOFTWARE", "1")
hl.env("WLR_NO_HARDWARE_CURSORS", "1")
hl.config({
cursor = {
no_hardware_cursors = true,
},
})
end
+2
View File
@@ -0,0 +1,2 @@
#%PAM-1.0
auth include system-auth
+8
View File
@@ -0,0 +1,8 @@
[Plymouth Theme]
Name=Blob
Description=Blob boot splash
ModuleName=script
[script]
ImageDir=/usr/share/plymouth/themes/blob
ScriptFile=/usr/share/plymouth/themes/blob/blob.script
+70
View File
@@ -0,0 +1,70 @@
Window.SetBackgroundTopColor(0.059, 0.063, 0.071);
Window.SetBackgroundBottomColor(0.059, 0.063, 0.071);
screen.width = Window.GetWidth();
screen.height = Window.GetHeight();
logo.image = Image("logo.png");
logo.width = logo.image.GetWidth();
logo.height = logo.image.GetHeight();
scale_x = screen.width / logo.width;
scale_y = screen.height / logo.height;
scale = scale_x;
if (scale_y < scale_x) {
scale = scale_y;
}
if (scale != 1) {
logo.image = logo.image.Scale(logo.width * scale, logo.height * scale);
logo.width = logo.image.GetWidth();
logo.height = logo.image.GetHeight();
}
logo.sprite = Sprite(logo.image);
logo.sprite.SetX((screen.width - logo.width) / 2);
logo.sprite.SetY((screen.height - logo.height) / 2);
logo.sprite.SetZ(0);
prompt.sprite = Sprite();
prompt.sprite.SetZ(10);
message.sprite = Sprite();
message.sprite.SetZ(10);
fun draw_prompt(text) {
if (text == "") {
prompt.sprite.SetImage(Image.Text(" ", 1, 1, 1));
return;
}
prompt.image = Image.Text(text, 0.8, 0.82, 0.85);
prompt.sprite.SetImage(prompt.image);
prompt.sprite.SetX((screen.width - prompt.image.GetWidth()) / 2);
prompt.sprite.SetY(screen.height * 0.82);
}
fun display_password_callback(prompt_text, bullets) {
dots = "";
index = 0;
while (index < bullets) {
dots += "*";
index++;
}
draw_prompt(prompt_text + " " + dots);
}
fun display_normal_callback() {
draw_prompt("");
}
fun message_callback(text) {
message.image = Image.Text(text, 0.8, 0.82, 0.85);
message.sprite.SetImage(message.image);
message.sprite.SetX((screen.width - message.image.GetWidth()) / 2);
message.sprite.SetY(screen.height * 0.9);
}
Plymouth.SetDisplayPasswordFunction(display_password_callback);
Plymouth.SetDisplayNormalFunction(display_normal_callback);
Plymouth.SetMessageFunction(message_callback);
+38
View File
@@ -0,0 +1,38 @@
-- Hyprland config for the SDDM Wayland greeter. SDDM starts the greeter itself
-- once the compositor is up, so nothing here launches anything.
-- The greeter runs as SDDM's own user, with no BLOB_PATH and no read access to
-- the checkout, so it calls the copy install.sh puts in /usr/local/bin rather
-- than loading the Lua modules the session uses. A detector that is not there
-- yet reads as no acceleration, which is the fallback that always draws.
local function gpu_is_accelerated()
local pipe = io.popen("/usr/local/bin/blob-hw-gpu-accelerated >/dev/null 2>&1 && echo OK")
if not pipe then
return false
end
local output = pipe:read("*a") or ""
pipe:close()
return output:match("OK") ~= nil
end
if not gpu_is_accelerated() then
hl.env("LIBGL_ALWAYS_SOFTWARE", "1")
hl.env("WLR_NO_HARDWARE_CURSORS", "1")
end
hl.config({
cursor = {
no_hardware_cursors = true,
},
misc = {
disable_hyprland_logo = true,
disable_splash_rendering = true,
force_default_wallpaper = 0,
},
animations = {
enabled = false,
},
})
+16
View File
@@ -0,0 +1,16 @@
[General]
DisplayServer=wayland
[Wayland]
CompositorCommand=start-hyprland -- --config /etc/sddm/hyprland-greeter.lua
[Theme]
Current=
[Autologin]
User=
Session=
[Users]
RememberLastUser=true
RememberLastSession=true
+21
View File
@@ -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%
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -14,7 +14,7 @@ theme[hi_fg]="{{ accent }}"
theme[selected_bg]="{{ selection }}"
# Foreground color of selected item in processes box
theme[selected_fg]="{{ accent }}"
theme[selected_fg]="{{ selection_foreground }}"
# Color of inactive/disabled text
theme[inactive_fg]="{{ muted }}"
@@ -23,7 +23,7 @@ theme[inactive_fg]="{{ muted }}"
theme[graph_text]="{{ light_foreground }}"
# Background color of the percentage meters
theme[meter_bg]="{{ selection }}"
theme[meter_bg]="{{ lighter_background }}"
# Misc colors for processes box including mini cpu graphs, details memory graph and details status text
theme[proc_misc]="{{ light_foreground }}"
+50
View File
@@ -0,0 +1,50 @@
{
"$schema": "https://github.com/fastfetch-cli/fastfetch/raw/dev/doc/json_schema.json",
"logo": {
"type": "file",
"source": "~/.config/blob/branding/about.txt",
"padding": {
"top": 2,
"left": 2,
"right": 6
}
},
"display": {
"separator": " ",
"key": {
"width": 9
},
"color": {
"title": "{{ accent_ansi }}",
"keys": "{{ accent_ansi }}",
"output": "{{ foreground_ansi }}"
}
},
"modules": [
{
"type": "title",
"format": "{user-name}@{host-name}"
},
"break",
{ "type": "os", "key": "os" },
{ "type": "kernel", "key": "kernel" },
{ "type": "uptime", "key": "uptime" },
{ "type": "packages", "key": "packages" },
{ "type": "shell", "key": "shell" },
"break",
{ "type": "wm", "key": "wm" },
{ "type": "terminal", "key": "term" },
{ "type": "display", "key": "display" },
"break",
{ "type": "cpu", "key": "cpu" },
{ "type": "gpu", "key": "gpu" },
{ "type": "memory", "key": "memory" },
{ "type": "disk", "key": "disk" },
"break",
{
"type": "colors",
"symbol": "circle",
"paddingLeft": 9
}
]
}
+41
View File
@@ -0,0 +1,41 @@
@define-color window_bg_color {{ background }};
@define-color window_fg_color {{ foreground }};
@define-color view_bg_color {{ background }};
@define-color view_fg_color {{ foreground }};
@define-color headerbar_bg_color {{ lighter_background }};
@define-color headerbar_fg_color {{ foreground }};
@define-color headerbar_border_color {{ dark_background }};
@define-color headerbar_backdrop_color {{ background }};
@define-color sidebar_bg_color {{ lighter_background }};
@define-color sidebar_fg_color {{ foreground }};
@define-color sidebar_backdrop_color {{ background }};
@define-color sidebar_border_color {{ dark_background }};
@define-color secondary_sidebar_bg_color {{ lighter_background }};
@define-color secondary_sidebar_fg_color {{ foreground }};
@define-color secondary_sidebar_backdrop_color {{ background }};
@define-color secondary_sidebar_border_color {{ dark_background }};
@define-color card_bg_color {{ lighter_background }};
@define-color card_fg_color {{ foreground }};
@define-color dialog_bg_color {{ background }};
@define-color dialog_fg_color {{ foreground }};
@define-color popover_bg_color {{ lighter_background }};
@define-color popover_fg_color {{ foreground }};
@define-color accent_bg_color {{ accent }};
@define-color accent_fg_color {{ background }};
@define-color accent_color {{ accent }};
@define-color destructive_bg_color {{ red }};
@define-color destructive_fg_color {{ background }};
@define-color destructive_color {{ red }};
@define-color success_color {{ green }};
@define-color warning_color {{ yellow }};
@define-color error_color {{ red }};
@define-color theme_bg_color {{ background }};
@define-color theme_fg_color {{ foreground }};
@define-color theme_base_color {{ background }};
@define-color theme_text_color {{ foreground }};
@define-color theme_selected_bg_color {{ accent }};
@define-color theme_selected_fg_color {{ background }};
@define-color insensitive_bg_color {{ dark_background }};
@define-color insensitive_fg_color {{ muted }};
@define-color borders {{ dark_background }};
+42
View File
@@ -0,0 +1,42 @@
add_newline = true
command_timeout = 200
format = "$directory$git_branch$git_status$character"
palette = "blob"
[palettes.blob]
accent = "{{ accent }}"
foreground = "{{ foreground }}"
muted = "{{ muted }}"
success = "{{ green }}"
warning = "{{ yellow }}"
error = "{{ red }}"
[character]
error_symbol = "[✗](bold error)"
success_symbol = "[](bold accent)"
[directory]
style = "foreground"
truncation_length = 2
truncation_symbol = "…/"
repo_root_style = "bold accent"
repo_root_format = "[$repo_root]($repo_root_style)[$path]($style)[$read_only]($read_only_style) "
[git_branch]
format = "[$branch]($style) "
style = "italic accent"
[git_status]
format = '[$all_status ]($style)'
style = "warning"
ahead = "⇡${count} "
diverged = "⇕⇡${ahead_count}⇣${behind_count} "
behind = "⇣${count} "
conflicted = " "
up_to_date = " "
untracked = "? "
modified = " "
stashed = ""
staged = ""
renamed = ""
deleted = ""
+12 -1
View File
@@ -43,6 +43,7 @@ of the `blob` listing.
| `blob-bg-cache` | Cache background switcher thumbnails for the current theme | - |
| `blob-bg-current` | Show current background | - |
| `blob-bg-install` | Open the current theme's user background folder | - |
| `blob-bg-monitor` | Assign a wallpaper to one monitor, leaving the others alone | <--list\|--clear-all\|<output> <image>\|<output> --clear> |
| `blob-bg-next` | Cycle to the next background for the current theme | - |
| `blob-bg-set` | Set the current background image | <path-to-image> |
| `blob-bg-switcher` | Open the Blob background switcher | - |
@@ -138,6 +139,7 @@ of the `blob` listing.
| Command | Does | Arguments |
| --- | --- | --- |
| `blob-display-arrange` | Apply a monitor layout at runtime without touching monitors.lua | <list\|apply <keyword>...\|reset> |
| `blob-display-size` | Scale text everywhere — blob shell, GTK apps, and terminals | [size\|reset] |
| `blob-display-state` | Print monitor panel state for the shell | - |
@@ -204,6 +206,7 @@ of the `blob` listing.
| `blob-hw-display` | Print the most likely display backlight device. | - |
| `blob-hw-external` | Returns true when an external monitor is physically connected. | - |
| `blob-hw-fingerprint` | Returns true when a fingerprint reader is present (hidden) | - |
| `blob-hw-gpu-accelerated` | Detect whether a DRM device with an accelerated driver is present. | - |
| `blob-hw-hybrid-gpu` | Detect whether the system has an active hybrid GPU configuration | - |
| `blob-hw-laptop` | Returns true when running on a laptop (has a lid or laptop chassis). | - |
| `blob-hw-laptop-closed` | Returns true when the laptop lid is closed (hidden) | - |
@@ -293,6 +296,7 @@ of the `blob` listing.
| `blob-launch-or-focus-webapp` | Launch or focus on a given web app identified by the window-pattern. | <window-pattern> <url-and-flags...> |
| `blob-launch-screensaver` | Launch the Blob screensaver in the default terminal on the system with the correct font configuration. | - |
| `blob-launch-shell` | Launch the Blob shell with its log kept in the journal (hidden) | - |
| `blob-launch-terminal` | Launch the default terminal | [command [args...]] |
| `blob-launch-tui` | Launch a TUI command in the default terminal with Blob styling | [--app-id=<app-id>] <command> [args...] |
| `blob-launch-webapp` | Launch a URL as a web app in the default supported browser | <url> |
@@ -346,6 +350,12 @@ of the `blob` listing.
| --- | --- | --- |
| `blob-osd` | Show the Blob Quickshell on-screen display | [-i\|--icon <icon>] [-m\|--message <text>] [-p\|--progress <0-100>] [-d\|--duration <ms>] |
## packages
| Command | Does | Arguments |
| --- | --- | --- |
| `blob-packages-check` | Verify every package list entry resolves, ignoring extra repositories (hidden) | - |
## pkg
| Command | Does | Arguments |
@@ -523,6 +533,7 @@ of the `blob` listing.
| Command | Does | Arguments |
| --- | --- | --- |
| `blob-terminal-exec` | Run a command in the default terminal (hidden) | [--print-id] [--app-id=<app-id>] [--title=<title>] [-e] <command> [args...] |
| `blob-theme-browser` | Apply the current theme color to Chromium, Chrome, Edge, and Brave (hidden) | - |
| `blob-theme-browser-policy` | Write the current theme color into the browser policy directories (hidden) | <rrggbb> |
| `blob-theme-color` | Resolve semantic colors from an Blob theme colors.toml (hidden) | [--file <colors.toml>] (--all \| --raw \| <key> [fallback]) |
@@ -620,4 +631,4 @@ of the `blob` listing.
## Totals
291 commands.
294 commands.
+234
View File
@@ -0,0 +1,234 @@
# 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, everything under `~/.config`, and the login shell profile |
| `install/steps/services.sh` | system services, systemd drop-ins, user units |
| `install/steps/theme.sh` | the wallpaper link, the first theme, the first background |
| `install/steps/login.sh` | SDDM, the session entry, and the first launch |
## Steps
1. **Directories** are created first, because nothing below can write into a
directory that is not there: `~/.config/blob`, `~/.config/systemd/user/app.slice.d`,
`~/.local/state/blob`, `~/.local/share` and `~/.cache/blob`. A fresh Arch
install has none of them.
2. **`BLOB_PATH`** is symlinked to the checkout at `~/.local/share/blob`. Nothing
under `bin/`, `shell/`, `themes/` or `default/` is ever copied. `ln` does not
create the parent of a link, so this step makes `~/.local/share` itself rather
than assuming step 1 ran.
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`. A failed batch refreshes the databases and retries,
then falls back to one package at a time, so a single name that no longer
resolves cannot cost you the other hundred and the config, service and login
steps below. See [packages.md](packages.md).
4. **Config** is copied into `~/.config`: `hypr/`, the themed app configs,
`blob/shell.json`, the uwsm environment, `fontconfig/fonts.conf`, and the
icon font, and the font cache is refreshed. The shell draws every icon as a
Nerd Font glyph in the `monospace` family, and on a fresh Arch install
fontconfig resolves that to a font with no such glyphs, so the bar and its
panels come up full of blanks. `fonts.conf` binds `monospace` to
`JetBrainsMono Nerd Font`; `blob font set` rewrites the same file, and the
installer then reports it as a local change and keeps your choice.
`~/.bashrc` gets one line sourcing `session/bashrc`. The line is added once,
a `.bak` is kept, and a `~/.bashrc` that already has it is left alone.
`session/profile.sh` also goes to `/etc/profile.d/blob.sh`, which sources
`session/env-bootstrap` and is what puts `blob` on `PATH` in a login shell.
Without it `BLOB_PATH` and `$BLOB_PATH/bin` are only set by
`~/.config/uwsm/env.d/10-blob`, which uwsm reads when the graphical session
starts, so `blob` is not found from a TTY. A shell that was already open when
the installer ran keeps its old `PATH`; open a new one, or
`source ~/.local/share/blob/session/env-bootstrap`.
5. **The first theme** is applied. `~/wallpapers` is symlinked to the checkout's
`wallpapers/`, `flats` is applied with `BLOB_THEME_HEADLESS=1` (no shell or
session bus exists yet), and the background symlink is pointed at the first
image in `~/wallpapers`. Without this the first login has no palette and no
wallpaper, which draws as a black desktop. It then links the per-app theme
files the pipeline renders but no app would otherwise find:
`~/.config/btop/themes/current.theme` (btop's `color_theme = "current"`) and,
only when `~/.config/nvim/lua/plugins` already exists, `theme.lua` there.
Every step is skipped when a theme, a background, a wallpaper directory or a
theme file of your own is already in place. A theme counts as in place only
when the staged theme under `~/.local/state/blob/current/theme` is there too,
so a recorded theme name left without its palette is applied again rather
than skipped.
6. **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.
7. **systemd drop-ins** are written under `/etc`: the shutdown timeouts, the
oomd pressure thresholds, the plocate prune paths, and the sleep hooks.
8. **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, and `plymouth-start.service` and
`plymouth-quit-wait.service` are masked because an unconfigured plymouth
holds the console and leaves the machine with neither a greeter nor a TTY.
`blob boot` unmasks them when it sets the splash up. See
[packages.md](packages.md#the-boot-splash).
9. **The login screen** is SDDM. It has to be installed first: if
`sddm.service` is not there, the installer says whether the package is
missing or whether systemd has simply not re-read its units yet, and carries
on without enabling anything. A unit counts as present when `systemctl cat`
finds it or when its file is under `/etc/systemd/system`,
`/run/systemd/system`, `/usr/local/lib/systemd/system` or
`/usr/lib/systemd/system` - `systemctl` goes through the service manager, so
it reports a perfectly good unit as missing inside a chroot or right after
pacman wrote it.
Three pieces have to be in place:
| File | Why |
| --- | --- |
| `/usr/local/share/wayland-sessions/blob.desktop` | the session SDDM offers |
| `/etc/sddm.conf.d/zz-blob.conf` | Wayland greeter, run on Hyprland, stock theme, remember the last session |
| `/etc/sddm/hyprland-greeter.lua` | the greeter's own tiny Hyprland config |
| `/usr/local/bin/blob-hw-gpu-accelerated` | the renderer check that config runs, where SDDM's user can read it |
| `/etc/pam.d/blob-lock-password` | the PAM stack the lock screen authenticates against |
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. The installer checks that the `CompositorCommand` named in
`zz-blob.conf` is actually installed and warns when it is not, because SDDM
with a compositor command it cannot run starts, fails, and leaves the machine
sitting at a text console with no explanation. That greeter config has to be Lua: Hyprland dropped the old
hyprlang format, and a config it cannot parse leaves the greeter with no
compositor and the screen black. `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.
The `zz-` prefix is load-bearing. SDDM reads `/etc/sddm.conf.d` in
alphabetical order and the last file wins, so a leftover `10-wayland.conf`,
`10-theme.conf` or `autologin.conf` from another desktop would otherwise
override Blob's. For the same reason `zz-blob.conf` blanks `Theme` and
`Autologin`: a greeter pointed at an uninstalled theme, or an autologin
pointed at a session entry that no longer exists, leaves SDDM on a black
screen it never falls back from, and the only way in is a TTY.
The Omarchy ISO wrote exactly such an autologin, owned by no package, so it
outlives Omarchy. The installer reports it and overrides it; deleting it stops
the warning:
```bash
sudo rm /etc/sddm.conf.d/autologin.conf
```
`--autologin` writes `zzz-blob-autologin.conf`, the only file that sorts after
`zz-blob.conf`.
10. **Launch.** `sddm`, `hyprland`, `quickshell` and `uwsm` are checked one last
time and any that are missing are named, because that is the whole difference
between a desktop and a console. Then, 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/zzz-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 |
## The interactive shell
`session/bashrc` is sourced from `~/.bashrc` and returns immediately in a
non-interactive shell, so it cannot disturb `scp` or `rsync`. It does three
things, each only when the tool is installed:
| Line | Effect |
| --- | --- |
| `session/env-bootstrap` | `BLOB_PATH` and `$BLOB_PATH/bin` on `PATH`, so `blob` resolves |
| `starship init bash` | the themed prompt, see [themes.md](themes.md#the-prompt) |
| `zoxide init bash --cmd cd` | `cd` learns the directories you actually use |
`--cmd cd` replaces `cd` rather than adding a `z` command, so
`cd blomarchy` jumps to the last matching directory you have visited from
anywhere, while `cd ./relative/path`, `cd -` and `cd` with no argument keep
behaving exactly as the builtin does. `cdi` picks from the matches
interactively through `fzf`, and `zoxide query -ls` prints the database.
The database is built as you go: it learns a directory the first time you `cd`
there, so the jumping is only as good as the history behind it and a fresh
install knows nothing yet.
## The lock screen
The lock plugin watches `/etc/pam.d/blob-lock-password` and refuses to lock at
all when it is not there - `blob system lock` returns `missing-pam` and nothing
appears, which reads as a keybinding that does nothing. The installer writes it;
it is one line including `system-auth`, the same stack every other Wayland
locker on Arch authenticates against.
Fingerprint unlock is separate and opt-in: `blob setup-security-fingerprint`
writes `/etc/pam.d/blob-lock-fingerprint`, and the lock only offers fingerprint
when that file and a reader are both present.
## Choosing a renderer
Hyprland renders through Mesa, and on an adapter Mesa has no driver for it exits
with `failed to create dri2 screen` instead of drawing. The greeter is Hyprland
too, so that is a black screen at boot and a second one after login, which is
how a machine with no real GPU behind its display arrives.
`blob-hw-gpu-accelerated` decides which way to go. It reads the kernel driver of
each `/sys/class/drm/card*` from sysfs, not from `lspci`, which reads PCI config
space and resumes runtime-suspended GPUs. A card driven by `amdgpu`, `i915`,
`nouveau`, `nvidia`, `nvidia-drm`, `radeon` or `xe` is a real integrated or
dedicated GPU and hardware rendering is left alone. Everything else - `vmwgfx`,
`vboxvideo`, `qxl`, `bochs-drm`, `cirrus`, `simpledrm`, `vkms`, `virtio_gpu` -
is virtual or framebuffer-only, and two settings are applied:
| Setting | Effect |
| --- | --- |
| `LIBGL_ALWAYS_SOFTWARE=1` | Mesa uses llvmpipe, which renders on the CPU |
| `WLR_NO_HARDWARE_CURSORS=1`, `cursor:no_hardware_cursors` | the cursor is drawn by the compositor, since there is no plane to put it on |
llvmpipe is slow and it draws. A machine with a real GPU never touches it.
Two places read the detector, because they run as different users:
| Where | How |
| --- | --- |
| the session | `default/hypr/renderer.lua`, required from `envs.lua` beside `nvidia.lua`, through `$BLOB_PATH/bin` |
| the greeter | `default/sddm/hyprland-greeter.lua`, through `/usr/local/bin/blob-hw-gpu-accelerated` |
The greeter runs as SDDM's own user, with no `BLOB_PATH` and no read access to
your home directory, so `install.sh` copies the detector to `/usr/local/bin`.
A detector that is not there yet reads as no acceleration, which is the fallback
that always draws.
To add a driver to the accelerated list, edit `accelerated_drivers` in
`bin/blob-hw-gpu-accelerated` and rerun `./install.sh` so the greeter's copy
follows. `virtio_gpu` is deliberately not on it: QEMU only accelerates it when
the host enables virgl, and Mesa fails the same way when it does not.
To see the difference by hand, from a console:
```bash
Hyprland # fails with the dri2 error
LIBGL_ALWAYS_SOFTWARE=1 Hyprland # starts
```
## Uninstall
`./uninstall.sh` removes the config, the user units, the `/etc` drop-ins, the
session entry, the wallpaper link 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.
+2 -1
View File
@@ -11,9 +11,10 @@ Personal overrides in `hypr/bindings.lua` win over the defaults, and
| Keys | Action |
| --- | --- |
| `SUPER + ALT + SPACE` | Blob menu |
| `SUPER + ALT + W` | Wallpaper picker |
| `SUPER + ALT + W` | Wallpapers |
| `SUPER + CTRL + M` | System monitor |
| `SUPER + CTRL + Q` | Quick settings |
| `SUPER + SHIFT + CTRL + D` | Display layout |
| `SUPER + SPACE` | Apps menu |
## Applications
+95 -2
View File
@@ -58,12 +58,105 @@ than usual.
Two are worth knowing about:
- `xdg-terminal-exec` is load-bearing. Every TUI launcher's `Exec=` line calls
it, so if its AUR build ever fails, TUI shortcuts stop opening.
- `xdg-terminal-exec` used to be load-bearing: every TUI launcher, the floating
launcher behind most of the menu, and `$TERMINAL` called it by name, so a
failed AUR build silently broke about half the menu. They all go through
`blob-terminal-exec` now, which prefers it and falls back to `foot`, then
`kitty`, both from the official repositories.
- `aether` is in the AUR at a version newer than the one the repository shipped.
`quickshell` never came from the Omarchy repository - it is in official `extra`.
## Installing packages
`install.sh` installs what is missing before it writes any config:
```
packages/blob.packages 123 packages from core, extra, multilib
packages/blob-aur.packages 11 packages with no official equivalent
```
Only missing packages are touched, through `pacman -S --needed` and
`yay -S --needed`. An entry is treated as already installed, and skipped, when a
package of that name is installed, when an installed package provides that name,
or when the name is a group whose members are all installed - so `base-devel` and
packages pulled in under a different name no longer come back on every run. AUR
packages are built one at a time, so a build that fails is reported at the end
instead of stopping the install.
Repo packages go in one `pacman` call. If that call fails the databases are
refreshed with `pacman -Sy` and it is tried again, because a stale database is
what turns an installed-and-present package into `target not found`. If it still
fails the packages are installed one at a time, so one unresolvable name does not
block the other hundred - which is how a missing `sddm` used to turn into a
machine with no login screen. `install.sh` ends by naming any of `sddm`,
`hyprland`, `quickshell` and `uwsm` that are still not installed.
`./install.sh --check` lists what it would install without installing anything,
and `--skip-packages` does the directories and config only.
### Validating the lists
```
blob-packages-check
```
`pacman -Si` and `yay -Si` both search *every* configured repository, so a
package that exists only in a third-party repo passes them and then fails on a
machine without that repo. `blob-packages-check` names the repositories
explicitly instead: official entries must be in core, extra or multilib, and AUR
entries must have an exact `aur/` match and must not be in an official repo.
Run it after editing either list. It caught `localsend` and `tzupdate` sitting in
the official list when they only exist in the Omarchy repository, and `awww`,
`lazydocker` and `woff2-font-awesome` sitting in the AUR list when they are in
official `extra`.
### Bootstrapping an AUR helper
A fresh Arch install has no AUR helper, and `yay` is not in the official
repositories. `install.sh` builds `yay-bin` from the AUR once with `makepkg`
before installing anything from the AUR list, which is why `base-devel` and
`git` are in the official list.
An entry may be written `wanted|already-fine`, which asks for the first name but
accepts either as satisfying the requirement. That is how
`hyprland-preview-share-picker-git` avoids conflicting with the non-git build
that the Omarchy repository still ships.
Three of the AUR entries are load-bearing for this fork and were not in
Omarchy's own package list, because they were installed by hand on this machine:
| Package | Needed by |
| --- | --- |
| `python-pywal16` | `blob-wallpaper-set`, palette extraction |
| `python-terminaltexteffects` | `blob-screensaver`, the effects it draws |
| `awww` | the wallpaper daemon `blob-wallpaper-set` and `blob-theme-menu` drive |
| `playerctl` | the quick settings media row |
`playerctl` is in official `extra`, so it sits in the repo list.
## The boot splash
`plymouth` is installed but left inert, and the splash is opt-in through one
command:
```
blob boot # branding/boot_flash.png
blob boot ~/some/image.png # or any other image
```
The reason it is opt-in is that an installed but *unconfigured* plymouth is not
harmless: it takes the console at boot and never hands it back, and
`getty@tty1` waits on `plymouth-quit-wait.service`, so the machine reaches
neither the greeter nor a TTY. `install.sh` therefore masks
`plymouth-start.service` and `plymouth-quit-wait.service`, and `blob boot`
unmasks them as part of configuring everything else - the theme in
`default/plymouth/`, the `plymouth` mkinitcpio hook, and the initramfs rebuild.
It finishes by printing the kernel parameters your bootloader still needs
(`quiet splash vt.global_cursor_default=0`), because that line is the one thing
it will not rewrite for a bootloader other than GRUB.
## Updating
```
+62 -4
View File
@@ -9,7 +9,7 @@ retints the running desktop.
| File | Used by |
| --- | --- |
| `colors.toml` | required; the palette everything else derives from |
| `backgrounds/` | the background switcher |
| `backgrounds/` | the background switcher; a theme without one keeps the current wallpaper |
| `shell.lock.toml` | lock screen surface tokens |
| `neovim.lua` | the editor colorscheme |
| `icons.theme` | GTK icon theme name |
@@ -27,9 +27,17 @@ the shell and the CLI never disagree about what a slot means.
## Templates
Each rendered file is picked up differently: `foot.ini` and `kitty.conf` by an
`include` in the shipped terminal config, `hyprland.lua` through Hyprland's Lua
module path, `shell.toml` and `chromium.theme` read straight from the state
directory, and `btop.theme`, `neovim.lua`, `gtk.css`, `starship.toml` and
`fastfetch.jsonc` through symlinks the installer makes into those apps' own
config trees.
`default/themed/*.tpl` are rendered per theme into
`~/.local/state/blob/current/theme/`. Nine ship: foot, kitty, btop, chromium,
hyprland, the screenshare picker, neovim, `shell.toml`, and zen.
`~/.local/state/blob/current/theme/`. Twelve ship: foot, kitty, btop, chromium,
hyprland, the screenshare picker, neovim, `shell.toml`, zen, `gtk.css`,
`starship.toml`, and `fastfetch.jsonc`.
An app picks its colors up in one of two ways. Most include the generated file
directly, so nothing has to run:
@@ -41,11 +49,56 @@ include ~/.local/state/blob/current/theme/kitty.conf
Zen does the same through a CSS import in `userChrome.css`. The rest are pushed
by a short applier listed in `post_theme_commands` inside `blob-theme-set`:
`blob-theme-foot`, `blob-theme-browser`, `blob-restart-terminal`,
`blob-restart-btop`, `blob-hypr-restart`.
`blob-restart-btop`, `blob-hypr-restart`, `blob-theme-gtk`.
## GTK apps
Nautilus and the other GTK apps read two things. The palette comes from
`gtk.css`, linked into both `~/.config/gtk-3.0/gtk.css` and
`~/.config/gtk-4.0/gtk.css`, which redefines the libadwaita named colors
(`window_bg_color`, `headerbar_bg_color`, `accent_bg_color` and the rest) so a
stock Adwaita app is drawn in the theme's own colors. The light or dark
preference comes from `blob-theme-gtk`, which reads the theme's `mode` and sets
`org.gnome.desktop.interface color-scheme` and `gtk-theme` through `gsettings`.
The preference applies to running apps immediately; the CSS is read at startup,
so an app that was already open keeps its old colors until it is restarted.
Adding a template for another app means dropping a `.tpl` in `default/themed/`
and, if it cannot include a file, adding an applier to that list.
## Tokens
A template may reference `{{ key }}` for any resolved palette key, plus three
derived forms: `{{ key_strip }}` without the leading `#`, `{{ key_rgb }}` as
`R,G,B`, and `{{ key_ansi }}` as `38;2;R;G;B`, which is what a terminal program
that takes a raw escape sequence rather than a hex color needs.
## About
`fastfetch.jsonc` is linked to `~/.config/fastfetch/config.jsonc`. It draws
`~/.config/blob/branding/about.txt` as the logo, with 2 columns of padding to
its left and 6 between it and the modules - the exact numbers
`blob-launch-about` measures the About window against, so replacing the logo
resizes the window to fit it. Title and keys take the theme's accent.
A config of your own at that path is a regular file rather than the symlink, and
`blob-launch-about` leaves the window sizing alone when it finds one.
## The prompt
`starship.toml` is linked to `~/.config/starship.toml`. It carries a
`[palettes.blob]` block built from the theme - `accent`, `foreground`, `muted`,
`success`, `warning`, `error` - and every style in the file names one of those
rather than a literal color, so the prompt moves with the theme. Starship reads
its config on each prompt, so a theme change shows up on the next command, in
shells that are already open.
`starship init bash` runs from `session/bashrc`, which `install.sh` sources from
`~/.bashrc` with a single line. That file returns immediately in a
non-interactive shell, so it cannot disturb `scp` or `rsync`, and it also
sources `session/env-bootstrap` so `blob` is on `PATH` in any interactive shell.
## The three ways to set a theme
| Command | Does |
@@ -66,3 +119,8 @@ Twenty-two came across from upstream, plus `flats` and `pitch-dark`. Their
`backgrounds/` directories are empty on purpose: the upstream images were
Omarchy branding, and `~/wallpapers` holds 103 of your own. A theme with no
background of its own leaves the current wallpaper alone.
Because of that, a machine with no wallpaper yet would get none from the theme
either. The installer covers the gap: it symlinks `~/wallpapers` to the
checkout, applies `flats`, and points the background symlink at the first image
it finds. See [install.md](install.md).
+58
View File
@@ -52,3 +52,61 @@ binding to the shell's own audio, brightness and media services. Polling only
runs while a panel is open, so the cost is bounded, but wiring these to the
services is the obvious next refinement and would remove the last per-tick
process spawns.
## Display layout
`blob.display-layout` arranges monitors. Opened with `Super + Shift + Ctrl + D`
or the Blob menu's Display layout row.
A scaled picture of the desktop, one draggable rectangle per monitor. Dropping
one applies the new position immediately. Edges snap to a neighbour's edge
within 60 logical pixels, so monitors end up touching exactly. Per-monitor
controls: mode, scale, enable or disable, and mirror.
**Scale options are filtered, not free.** Hyprland steps fractional scaling in
1/120 and rejects a scale that does not land on a whole number of physical
pixels on both axes. `DisplayModel.scaleIsValid` applies that rule, so only
usable scales are offered. On a 1920x1080 panel that means 1.0 and 1.2 are
adjacent with nothing between them, which is the constraint `hypr/monitors.lua`
documents by hand.
**Nothing is written to `monitors.lua`.** Every change goes through
`hyprctl keyword monitor` and lasts until the config reloads or the session
ends. "Reset to monitors.lua" runs `hyprctl reload` and makes the checked-in
config authoritative again. To keep a layout, edit `monitors.lua` yourself -
`blob display arrange list` prints the current layout in the exact keyword form,
ready to copy.
Rotation and primary-monitor selection are not implemented. `DisplayModel` reads
`transform` and round-trips it, so a rotated monitor is drawn and preserved
correctly, but nothing in the widget sets it.
## Wallpapers
`blob.displays` picks wallpapers. Opened with `Super + Alt + W` or the Blob
menu's Wallpaper row.
A thumbnail grid of `~/wallpapers`, with a row of chips selecting the target:
"All monitors" sets the global wallpaper through `blob-bg-set`, and a specific
output assigns just that monitor through `blob-bg-monitor`, which symlinks the
image under `~/.local/state/blob/backgrounds/<output>`. An output with its own
wallpaper is marked with an asterisk.
`blob.background` already drew one window per screen but pointed them all at the
same image. It now resolves per screen: a monitor with an assignment shows it,
and a monitor without one shows the global wallpaper. The crossfade machinery
stays on the global path only, so an assigned monitor swaps without the reveal
animation rather than risking that code.
| Command | Does |
| --- | --- |
| `blob bg set <image>` | set the global wallpaper |
| `blob bg monitor <output> <image>` | assign a wallpaper to one monitor |
| `blob bg monitor <output> --clear` | fall back to the global wallpaper |
| `blob bg monitor --list` | show assignments |
| `blob display arrange list` | print the layout as monitor keywords |
| `blob display arrange apply <kw>` | apply a keyword now |
| `blob display arrange reset` | reload, restoring monitors.lua |
Both widgets share `Ui/Chip.qml`, `Ui/Card.qml` and `Ui/CardIconButton.qml`
rather than carrying their own copies.
+2 -1
View File
@@ -1,8 +1,9 @@
-- Personal keybinding overrides on top of the defaults.
-- See current bindings and descriptions: blob menu keybindings --print
o.bind("SUPER + ALT + W", "Wallpaper picker", "blob-wallpaper-set --menu")
o.bind("SUPER + ALT + W", "Wallpapers", "blob-shell shell toggle blob.displays")
o.bind("SUPER + CTRL + M", "System monitor", "blob-shell shell toggle blob.sysmon")
o.bind("SUPER + SHIFT + CTRL + D", "Display layout", "blob-shell shell toggle blob.display-layout")
o.bind("SUPER + CTRL + Q", "Quick settings", "blob-shell shell toggle blob.quick-settings")
-- Apps on SUPER + SPACE, root menu on ALT, swapping the shipped defaults back.
+55 -145
View File
@@ -5,21 +5,28 @@ 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 <<USAGE
Usage: ./install.sh [OPTIONS]
--check Report what would change, write nothing
--force Overwrite files that have local changes
--help Show this message
Creates every directory Blob needs, installs the packages that are missing,
writes the config, enables the services, and opens the login screen.
--check Report what would change, write nothing
--force Overwrite files that have local changes
--skip-packages Do not install packages, only directories and config
--autologin Log this user straight into the Blob session
--no-launch Do not start the login screen at the end
--help Show this message
USAGE
exit 0
}
@@ -28,113 +35,21 @@ while (( $# > 0 )); do
case "$1" in
--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))
}
# 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
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
fi
note_change
if [[ $check == true ]]; then
say "would link BLOB_PATH -> $repo_dir"
return
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
fi
if [[ -e $dest ]] && cmp -s "$source" "$dest"; then
say "ok $label"
return
fi
note_change
if [[ -e $dest ]] && [[ $force == false ]]; then
say "DIFF $label (local changes kept; --force to overwrite)"
return
fi
if [[ $check == true ]]; then
say "would write $label"
return
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
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
fi
note_change
if [[ $check == true ]]; then
say "would install wayland session entry (needs sudo)"
return
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/theme.sh"
source "$repo_dir/install/steps/login.sh"
say "Blob installer"
say "repo: $repo_dir"
@@ -143,47 +58,34 @@ if [[ $check == true ]]; then
fi
say ""
create_directories
link_blob_path
install_packages
if [[ $check == false ]]; then
mkdir -p "$state_dir"/{toggles/hypr,indicators,notifications}
mkdir -p "$config_dir/blob"/{hooks,extensions,plugins,themed,themes}
mkdir -p "$HOME/wallpapers"
fi
install_shipped_config
refresh_font_cache
install_shell_profile
install_bash_integration
install_user_units
install_systemd_dropins
install_zen_config
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"
link_wallpapers
install_default_theme
install_default_background
link_app_themes
enable_system_services
mask_system_services
enable_user_units
install_login_config
install_session_entry
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
install_autologin
enable_display_manager
report_greeter_compositor
report_login_conflicts
report_desktop_essentials
say ""
if [[ $check == true ]]; then
@@ -191,6 +93,14 @@ if [[ $check == true ]]; then
(( changes == 0 )) || exit 1
else
say "$changes item(s) changed."
active_theme="$(cat "$current_state_dir/theme.name" 2>/dev/null || true)"
if [[ -n $active_theme ]]; then
say "Theme: $active_theme. Change it with 'blob theme set <name>'."
fi
say "New shells find the blob command through /etc/profile.d/blob.sh."
say "For this shell: source $blob_path/session/env-bootstrap"
say ""
say "Next: log out and pick the Blob session, then run 'blob theme set <name>'."
launch_desktop
fi
+7
View File
@@ -0,0 +1,7 @@
as_root() {
if (( EUID == 0 )); then
"$@"
else
sudo "$@"
fi
}
+120
View File
@@ -0,0 +1,120 @@
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/as-root.sh"
say() {
printf '%s\n' "$1"
}
note_change() {
changes=$((changes + 1))
}
unit_search_dirs=(
/etc/systemd/system
/run/systemd/system
/usr/local/lib/systemd/system
/usr/lib/systemd/system
)
system_unit_exists() {
local unit="$1" dir
systemctl cat -- "$unit" &>/dev/null && return 0
for dir in "${unit_search_dirs[@]}"; do
[[ -e $dir/$unit ]] && return 0
done
return 1
}
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"
}
+116
View File
@@ -0,0 +1,116 @@
# 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.
#
# ~/.local/share does not exist on a fresh Arch install, and ln does not create
# the parent of its link, so make it here rather than relying on another step
# having run first.
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
mkdir -p "$(dirname "$blob_path")"
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"
}
bashrc_line='[ -r "$HOME/.local/share/blob/session/bashrc" ] && . "$HOME/.local/share/blob/session/bashrc"'
install_bash_integration() {
local bashrc="$HOME/.bashrc"
if [[ -f $bashrc ]] && grep -Fqx "$bashrc_line" "$bashrc"; then
say "ok bashrc"
return 0
fi
note_change
if [[ $check == true ]]; then
say "would source session/bashrc from ~/.bashrc"
return 0
fi
[[ -f $bashrc ]] && cp "$bashrc" "$bashrc.bak"
printf '\n%s\n' "$bashrc_line" >>"$bashrc"
say "write bashrc"
}
refresh_font_cache() {
if ! command -v fc-cache &>/dev/null; then
say "SKIP font cache (fc-cache not installed)"
return 0
fi
if [[ $check == true ]]; then
say "would refresh the font cache"
return 0
fi
fc-cache -f "$font_dir" >/dev/null 2>&1 || true
say "ok font cache"
}
install_shell_profile() {
install_root_file "$repo_dir/session/profile.sh" /etc/profile.d/blob.sh "profile.d/blob.sh"
}
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"
}
+44
View File
@@ -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/.cache/blob"
)
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)"
}
+191
View File
@@ -0,0 +1,191 @@
session_entry_dir=/usr/local/share/wayland-sessions
session_entry_search_dirs=(/usr/local/share/wayland-sessions /usr/share/wayland-sessions)
greeter_config_dir=/etc/sddm.conf.d
greeter_compositor_config=/etc/sddm/hyprland-greeter.lua
# SDDM reads /etc/sddm.conf.d in alphabetical order and the last file wins, so
# Blob's drop-ins are named to sort after anything a distribution, an installer
# ISO, or a previous desktop left behind.
blob_greeter_conf="$greeter_config_dir/zz-blob.conf"
blob_autologin_conf="$greeter_config_dir/zzz-blob-autologin.conf"
greeter_gpu_detector=/usr/local/bin/blob-hw-gpu-accelerated
# SDDM's stock Wayland greeter runs on weston, which Blob does not install.
# Point it at Hyprland with a greeter-only config instead. That config is Lua:
# Hyprland dropped the old hyprlang format, and a config it cannot parse leaves
# the greeter with no compositor and the screen black.
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/zz-blob.conf" \
"$blob_greeter_conf" "sddm.conf.d/zz-blob.conf"
install_root_file "$repo_dir/bin/blob-hw-gpu-accelerated" \
"$greeter_gpu_detector" "blob-hw-gpu-accelerated" 0755
install_root_file "$repo_dir/default/pam.d/blob-lock-password" \
/etc/pam.d/blob-lock-password "pam.d/blob-lock-password"
}
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" "$blob_autologin_conf" "sddm.conf.d/zzz-blob-autologin.conf"
rm -f "$generated"
}
session_entry_exists() {
local entry="$1"
local dir
for dir in "${session_entry_search_dirs[@]}"; do
[[ -e $dir/$entry ]] && return 0
done
return 1
}
setting_value() {
local file="$1" key="$2"
sed -n "s/^[[:space:]]*$key[[:space:]]*=[[:space:]]*//p" "$file" | tail -1
}
# SDDM runs CompositorCommand to get a Wayland greeter on screen. When that
# binary is not installed SDDM fails with no greeter at all, which reads as a
# machine that boots to a console for no reason, so name it here instead.
report_greeter_compositor() {
local command_line binary
command_line="$(setting_value "$repo_dir/default/sddm/zz-blob.conf" CompositorCommand)"
binary="${command_line%% *}"
[[ -n $binary ]] || return 0
command -v "$binary" &>/dev/null && return 0
say "WARN the greeter's compositor command '$binary' is not installed"
say " SDDM cannot draw a greeter without it; it is set in $blob_greeter_conf"
}
# zz-blob.conf blanks Theme and Autologin, so a leftover drop-in cannot point the
# greeter at a theme that was uninstalled or autolog into a session that no
# longer exists. Either one leaves SDDM on a black screen it never falls back
# from. Report the leftovers anyway: the file is confusing to find later, and the
# Omarchy ISO wrote an autologin that no package owns, so it outlives Omarchy.
report_login_conflicts() {
local file session
local found=false
for file in "$greeter_config_dir"/*.conf; do
[[ -e $file ]] || continue
[[ $file == "$blob_greeter_conf" || $file == "$blob_autologin_conf" ]] && continue
session="$(setting_value "$file" Session)"
[[ -n $session ]] || continue
session_entry_exists "$session" && continue
found=true
say "WARN $file autologs into $session, which is not installed"
done
if [[ $found == true ]]; then
say " Blob's config overrides it, so the greeter still comes up."
say " Delete the file to stop it coming back."
fi
}
# 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
if package_installed sddm; then
say "WARN sddm is installed but sddm.service is not visible to systemd"
say " run 'sudo systemctl daemon-reload', then ./install.sh again"
else
say "WARN sddm is not installed; the desktop cannot start at boot"
say " install it with 'sudo pacman -S sddm', then ./install.sh again"
fi
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
if as_root systemctl enable sddm.service; then
say "enable sddm.service"
else
say "WARN could not enable sddm.service"
fi
fi
if [[ $target_needed == true ]]; then
if as_root systemctl set-default graphical.target; then
say "set default target to graphical.target"
else
say "WARN could not set the default target to graphical.target"
fi
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
say "No login screen to start: sddm is not installed."
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 || {
say "WARN sddm.service did not start; see 'systemctl status sddm'"
}
}
+154
View File
@@ -0,0 +1,154 @@
read_package_list() {
[[ -f $1 ]] || return 0
grep -vE '^[[:space:]]*#|^[[:space:]]*$' "$1"
}
package_installed() {
local name="$1" member
pacman -Q "$name" &>/dev/null && return 0
pacman -T "$name" &>/dev/null && return 0
local -a members=()
mapfile -t members < <(pacman -Sg "$name" 2>/dev/null | awk '{print $2}')
(( ${#members[@]} )) || return 1
for member in "${members[@]}"; do
pacman -Q "$member" &>/dev/null || return 1
done
}
# 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#*|}
package_installed "$wanted" && continue
[[ $alternative != "$entry" ]] && package_installed "$alternative" && 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)"
install_repo_packages "${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)"
install_aur_packages "${aur_missing[@]}"
fi
}
install_repo_packages() {
local package
local -a failed=()
as_root pacman -S --needed --noconfirm "$@" && return 0
say "WARN the batch install failed; refreshing the package databases"
as_root pacman -Sy --noconfirm || true
as_root pacman -S --needed --noconfirm "$@" && return 0
say "WARN still failing; retrying one package at a time"
for package in "$@"; do
as_root pacman -S --needed --noconfirm "$package" || failed+=("$package")
done
(( ${#failed[@]} == 0 )) && return 0
say "WARN ${#failed[@]} repo package(s) failed to install: ${failed[*]}"
}
desktop_essentials=(sddm hyprland quickshell uwsm)
report_desktop_essentials() {
local package
local -a missing=()
for package in "${desktop_essentials[@]}"; do
package_installed "$package" || missing+=("$package")
done
if (( ${#missing[@]} == 0 )); then
say "ok desktop essentials"
return 0
fi
say "WARN the desktop cannot start without: ${missing[*]}"
say " install them with 'sudo pacman -S ${missing[*]}', then ./install.sh again"
}
install_aur_packages() {
local package
local -a failed=()
for package in "$@"; do
yay -S --needed --noconfirm "$package" || failed+=("$package")
done
(( ${#failed[@]} == 0 )) && return 0
say "WARN ${#failed[@]} AUR package(s) failed to build: ${failed[*]}"
say "WARN the install continued; retry them with 'yay -S <name>'"
}
# 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
}
+151
View File
@@ -0,0 +1,151 @@
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.
#
# Blob configures no boot splash: no mkinitcpio hook, no kernel command line,
# and no theme. An unconfigured plymouth still takes the console at boot and
# never hands it back, and getty@tty1 waits on plymouth-quit-wait.service, so
# the machine comes up with no greeter and no TTY to fix it from.
masked_services=(
NetworkManager-wait-online.service
plymouth-start.service
plymouth-quit-wait.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)"
}
+144
View File
@@ -0,0 +1,144 @@
default_theme=flats
wallpaper_dir="$HOME/wallpapers"
current_state_dir="$HOME/.local/state/blob/current"
# ~/wallpapers is what the wallpaper picker and `blob wallpaper set` read, and
# the checkout already carries the collection. Link it for the same reason
# BLOB_PATH is a link: one copy, and edits in the checkout are live.
link_wallpapers() {
local source="$repo_dir/wallpapers"
local current=""
[[ -L $wallpaper_dir ]] && current="$(readlink -f "$wallpaper_dir")"
if [[ $current == "$source" ]]; then
say "ok wallpapers"
return 0
fi
if [[ -d $wallpaper_dir && ! -L $wallpaper_dir ]] && [[ -n "$(ls -A "$wallpaper_dir")" ]]; then
say "ok wallpapers (own directory kept)"
return 0
fi
note_change
if [[ $check == true ]]; then
say "would link $wallpaper_dir -> $source"
return 0
fi
[[ -d $wallpaper_dir && ! -L $wallpaper_dir ]] && rmdir "$wallpaper_dir"
ln -sfn "$source" "$wallpaper_dir"
say "link wallpapers -> $source"
}
# Without a theme there is no palette, no themed app config, and no background
# for the shell to draw, so the first login lands on a black screen that looks
# like a failed session. Applied headless: no shell and no session bus exist
# while the installer runs.
install_default_theme() {
local current_name=""
[[ -s $current_state_dir/theme.name ]] && current_name="$(cat "$current_state_dir/theme.name")"
if [[ -n $current_name && -f $current_state_dir/theme/colors.toml ]]; then
say "ok theme ($current_name)"
return 0
fi
note_change
if [[ $check == true ]]; then
say "would apply the $default_theme theme"
return 0
fi
if BLOB_PATH="$blob_path" BLOB_THEME_HEADLESS=1 PATH="$repo_dir/bin:$PATH" \
blob-theme-set "$default_theme" >/dev/null; then
say "theme $default_theme"
else
say "WARN could not apply the $default_theme theme"
say " the session will come up without a palette; run 'blob theme set $default_theme'"
fi
}
# Only a theme that ships its own backgrounds/ sets this link, and most ship
# none: they are meant to leave the current wallpaper alone. On a first install
# there is no current wallpaper, so pick one.
install_default_background() {
local link="$current_state_dir/background"
local search="$wallpaper_dir"
local first
if [[ -e $link ]]; then
say "ok background"
return 0
fi
# --check writes nothing, so the wallpaper link is not there yet to look in.
[[ -d $search ]] || search="$repo_dir/wallpapers"
first="$(find -L "$search" -maxdepth 1 -type f \
\( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.png' -o -iname '*.webp' \) \
2>/dev/null | sort | head -1)"
if [[ -z $first ]]; then
say "SKIP background (no image in $search)"
return 0
fi
note_change
if [[ $check == true ]]; then
say "would set the background to $(basename "$first")"
return 0
fi
ln -nsf "$first" "$link"
say "bg $(basename "$first")"
}
# The theme pipeline renders one file per app into the current-theme directory,
# but each app only ever reads its own config tree. These links are what join
# the two. Without them a theme switch silently leaves the app on stock colors,
# because nothing errors: the app just never sees the generated file.
link_theme_file() {
local theme_file="$1" dest="$2" label="$3"
local target="$current_state_dir/theme/$theme_file"
if [[ -L $dest ]] && [[ "$(readlink "$dest")" == "$target" ]]; then
say "ok $label"
return 0
fi
if [[ -e $dest && ! -L $dest ]] && [[ $force == false ]]; then
note_change
say "DIFF $label (own file kept; --force to replace)"
return 0
fi
note_change
if [[ $check == true ]]; then
say "would link $label"
return 0
fi
mkdir -p "$(dirname "$dest")"
ln -nsf "$target" "$dest"
say "link $label"
}
# btop reads color_theme = "current" out of its own themes directory, and
# Neovim loads whatever theme.lua its plugin directory holds. Neovim is only
# linked when that directory already exists: Blob ships no Neovim config, so
# creating the tree would be inventing a layout the user has not chosen.
link_app_themes() {
link_theme_file btop.theme "$config_dir/btop/themes/current.theme" "btop theme"
link_theme_file gtk.css "$config_dir/gtk-3.0/gtk.css" "gtk-3.0 theme"
link_theme_file gtk.css "$config_dir/gtk-4.0/gtk.css" "gtk-4.0 theme"
link_theme_file starship.toml "$config_dir/starship.toml" "starship prompt"
link_theme_file fastfetch.jsonc "$config_dir/fastfetch/config.jsonc" "fastfetch"
if [[ -d $config_dir/nvim/lua/plugins ]]; then
link_theme_file neovim.lua "$config_dir/nvim/lua/plugins/theme.lua" "neovim theme"
else
say "SKIP neovim theme (no ~/.config/nvim/lua/plugins)"
fi
}
+31
View File
@@ -0,0 +1,31 @@
# Packages this desktop needs from the AUR.
# Installed by install.sh with: yay -S --needed
#
# These have no official-repo equivalent. See docs/packages.md.
# Palette extraction behind blob-wallpaper-set; pywal16 is the maintained fork
python-pywal16|python-pywal
# Terminal text effects behind the screensaver; ttfx was the Omarchy build
python-terminaltexteffects
# Terminal dispatch; every TUI launcher's Exec= line calls it
xdg-terminal-exec
# Screenshare picker used by the Hyprland portal
hyprland-preview-share-picker-git|hyprland-preview-share-picker
# Theming GUI that writes the same colors.toml the CLI does
aether
# Fonts and icons with no official build
ttf-ia-writer
yaru-icon-theme
# Tooling
mise-bin
ufw-docker
# Only in the Omarchy repository as binaries; these are the AUR sources
tzupdate
localsend
+176
View File
@@ -0,0 +1,176 @@
# Packages this desktop needs from the official Arch repositories.
# Installed by install.sh with: pacman -S --needed
#
# Derived from Omarchy's base list, minus its own software and the features
# this fork dropped, plus what the fork's own scripts and widgets need.
# Compositor and shell
hyprland
mesa
quickshell
uwsm
sddm
hyprland-guiutils
hyprpicker
hyprsunset
xdg-desktop-portal-hyprland
xdg-desktop-portal-gtk
gtk4-layer-shell
qt6-wayland
qt6-imageformats
wl-clipboard
wtype
socat
inotify-tools
plymouth
polkit
# Shell widget backends
playerctl
pamixer
brightnessctl
ddcutil
# Networking
networkmanager
nss-mdns
avahi
wireless-regdb
# Bluetooth
bluez
bluez-utils
bluez-tools
# Input method, for the XCompose sequences blob-fcitx5.service serves
fcitx5
fcitx5-gtk
fcitx5-qt
# Audio
pipewire
pipewire-alsa
pipewire-jack
pipewire-pulse
wireplumber
alsa-utils
lsp-plugins-lv2
# Power and thermals
power-profiles-daemon
thermald
# Storage and devices
udiskie
gvfs-mtp
gvfs-nfs
gvfs-smb
dosfstools
exfatprogs
btrfs-progs
bolt
kernel-modules-hook
gnome-disk-utility
# Printing
cups
cups-filters
cups-pk-helper
system-config-printer
# Security
ufw
gnome-keyring
libsecret
# Boot and snapshots
limine
snapper
zram-generator
# System utilities
plocate
man-db
expac
pacman-contrib
# Terminals
foot
kitty
# Fonts and icons
ttf-jetbrains-mono-nerd
noto-fonts
noto-fonts-cjk
noto-fonts-emoji
fontconfig
woff2-font-awesome
gnome-themes-extra
# Image and video handling
imagemagick
libvips
webp-pixbuf-loader
ffmpegthumbnailer
# Theming
awww
# Capture
grim
slurp
gpu-screen-recorder
tesseract
tesseract-data-eng
qrencode
zbar
# Media
mpv
mpv-mpris
imv
evince
# Files
nautilus
nautilus-python
sushi
# Editor
neovim
# Browser for web apps
chromium
# Build tools, needed to bootstrap an AUR helper
base-devel
git
# Command line
bat
eza
fd
ripgrep
fzf
zoxide
jq
starship
lazygit
btop
fastfetch
inxi
dua-cli
tldr
whois
inetutils
unzip
yt-dlp
gum
less
python-gobject
# Containers
docker
docker-compose
docker-buildx
lazydocker
+10
View File
@@ -0,0 +1,10 @@
case $- in
*i*) ;;
*) return 0 ;;
esac
[ -r "$HOME/.local/share/blob/session/env-bootstrap" ] && . "$HOME/.local/share/blob/session/env-bootstrap"
command -v starship >/dev/null 2>&1 && eval "$(starship init bash)"
command -v zoxide >/dev/null 2>&1 && eval "$(zoxide init bash --cmd cd)"
+6
View File
@@ -0,0 +1,6 @@
# Put the blob command on PATH for login shells. The graphical session gets the
# same environment from ~/.config/uwsm/env.d/10-blob.
if [ -r "$HOME/.local/share/blob/session/env-bootstrap" ]; then
. "$HOME/.local/share/blob/session/env-bootstrap"
fi
+1 -1
View File
@@ -1,2 +1,2 @@
export TERMINAL=xdg-terminal-exec
export TERMINAL=blob-terminal-exec
export EDITOR="blob-launch-editor --inline"
+1 -1
View File
@@ -3,7 +3,7 @@
if [ -f "${BLOB_PATH%/}/session/uwsm/default" ]; then
. "${BLOB_PATH%/}/session/uwsm/default"
else
export TERMINAL=xdg-terminal-exec
export TERMINAL=blob-terminal-exec
export EDITOR="blob-launch-editor --inline"
fi
+38
View File
@@ -0,0 +1,38 @@
import QtQuick
import qs.Commons
Rectangle {
id: root
property string label: ""
property bool active: false
signal activated()
implicitWidth: text.implicitWidth + Style.spaceReal(16)
implicitHeight: Style.spaceReal(22)
radius: Style.cardRadius
opacity: enabled ? 1 : 0.4
color: root.active
? Color.accent
: (hover.hovered ? Util.alpha(Color.blue, 0.25) : Util.alpha(Color.background, Style.cardFillAlpha))
border.width: 1
border.color: root.active ? Color.accent : Util.alpha(Color.blue, Style.cardBorderAlpha)
Text {
id: text
anchors.centerIn: parent
text: root.label
textFormat: Text.PlainText
color: root.active ? Color.background : Color.foreground
font.family: Style.font.family
font.pixelSize: Style.font.bodySmall
}
HoverHandler {
id: hover
}
TapHandler {
onTapped: root.activated()
}
}
+1
View File
@@ -37,3 +37,4 @@ Card 1.0 Card.qml
CardHeader 1.0 CardHeader.qml
StatRow 1.0 StatRow.qml
CardIconButton 1.0 CardIconButton.qml
Chip 1.0 Chip.qml
+43 -3
View File
@@ -13,6 +13,17 @@ Item {
readonly property string home: Quickshell.env("HOME")
readonly property string stateHome: home + "/.local/state"
readonly property string currentBackgroundLink: stateHome + "/blob/current/background"
readonly property string monitorBackgroundDir: stateHome + "/blob/backgrounds"
// Per-monitor assignments, keyed by output name, written by blob-bg-monitor.
// A monitor with no entry falls back to the global background, so the
// transition machinery below stays on the single-image path it was built for.
property var monitorBackgrounds: ({})
function monitorBackgroundFor(name) {
var assigned = root.monitorBackgrounds[String(name || "")]
return assigned ? String(assigned) : ""
}
property string currentBackground: ""
property string displayedBackground: ""
@@ -32,6 +43,20 @@ Item {
function refreshBackground() {
if (!readlinkProc.running) readlinkProc.running = true
if (!monitorBackgroundProc.running) monitorBackgroundProc.running = true
}
function loadMonitorBackgrounds(raw) {
var next = ({})
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
var split = line.indexOf("\t")
if (split <= 0) continue
next[line.substring(0, split)] = line.substring(split + 1)
}
root.monitorBackgrounds = next
}
function setBackground(path, instant) {
@@ -128,6 +153,18 @@ Item {
}
}
Process {
id: monitorBackgroundProc
command: ["bash", "-c",
'dir="$1"; [[ -d $dir ]] || exit 0; ' +
'for link in "$dir"/*; do [[ -e $link ]] || continue; ' +
'printf "%s\\t%s\\n" "${link##*/}" "$(readlink -f "$link")"; done',
"--", root.monitorBackgroundDir]
stdout: StdioCollector {
onStreamFinished: root.loadMonitorBackgrounds(text)
}
}
IpcHandler {
target: "background"
@@ -187,6 +224,9 @@ Item {
screen: modelData
visible: !remapGuard.remapping
readonly property string assignedBackground: root.monitorBackgroundFor(modelData ? modelData.name : "")
readonly property bool hasAssignment: assignedBackground.length > 0
anchors { top: true; bottom: true; left: true; right: true }
ScreenMoveRemap {
@@ -221,7 +261,7 @@ Item {
Image {
id: base
anchors.fill: parent
source: root.imageUrl(root.displayedBackground)
source: root.imageUrl(panel.hasAssignment ? panel.assignedBackground : root.displayedBackground)
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
@@ -243,14 +283,14 @@ Item {
cache: false
smooth: true
mipmap: true
visible: root.oldBackground !== "" && root.revealProgress < 1
visible: !panel.hasAssignment && root.oldBackground !== "" && root.revealProgress < 1
onStatusChanged: panel.maybeStartReveal()
}
Item {
id: incomingLayer
anchors.fill: parent
visible: root.incomingBackground !== "" && incomingFrame.status === Image.Ready && (root.revealProgress >= 1 || panel.maskReady)
visible: !panel.hasAssignment && root.incomingBackground !== "" && incomingFrame.status === Image.Ready && (root.revealProgress >= 1 || panel.maskReady)
layer.enabled: root.incomingBackground !== "" && root.revealProgress < 1
layer.smooth: true
layer.effect: MultiEffect {
+6 -2
View File
@@ -39,8 +39,12 @@ BarWidget {
Process {
id: updateProc
command: ["blob-update-available"]
onExited: function(exitCode) {
root.updateAvailable = exitCode === 0
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: {
const pending = parseInt(String(text).trim(), 10)
root.updateAvailable = !isNaN(pending) && pending > 0
}
}
}
+2 -2
View File
@@ -8,7 +8,7 @@ BarWidget {
id: root
moduleName: "blob.menu"
readonly property string iconPath: Quickshell.env("HOME") + "/.config/blob/branding/blob_icon.svg"
readonly property string iconPath: Quickshell.env("HOME") + "/.config/blob/branding/chai.svg"
readonly property int iconSize: Math.round(Style.font.body * 1.35)
property string iconSvg: ""
@@ -48,7 +48,7 @@ BarWidget {
horizontalMargin: 7.5
onPressed: function(button) {
if (!root.bar) return
if (button === Qt.RightButton) root.bar.run("xdg-terminal-exec")
if (button === Qt.RightButton) root.bar.run("blob-terminal-exec")
else root.bar.run("blob-shell shell toggle blob.menu '{\"menu\":\"root\"}'")
}
+1
View File
@@ -1243,6 +1243,7 @@ Item {
font.family: row.iconFont.length > 0 ? row.iconFont : root.fontFamily
font.pixelSize: Style.font.iconLarge
width: Style.space(36)
elide: Text.ElideRight
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
anchors.left: parent.left
@@ -0,0 +1,237 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.Commons
import qs.Ui
import "DisplayModel.js" as DisplayModel
Item {
id: root
property var shell: null
property var manifest: null
property bool opened: false
readonly property string pluginId: (manifest && manifest.id) || "blob.display-layout"
readonly property int snapThreshold: 60
property var monitors: []
property string selectedName: ""
property bool layoutDirty: false
readonly property var selectedMonitor: {
for (var i = 0; i < root.monitors.length; i++)
if (root.monitors[i].name === root.selectedName) return root.monitors[i]
return null
}
function open(payloadJson) {
root.opened = true
root.refresh()
}
function close() {
root.opened = false
}
function dismiss() {
root.opened = false
if (root.shell && typeof root.shell.hide === "function")
root.shell.hide(root.pluginId)
}
function run(command) {
Quickshell.execDetached(["bash", "-c", command])
}
function refresh() {
if (!monitorProc.running) monitorProc.running = true
}
function loadMonitors(raw) {
var parsed = DisplayModel.parseMonitors(raw)
root.monitors = parsed
root.layoutDirty = false
if (root.selectedName.length > 0) {
for (var i = 0; i < parsed.length; i++)
if (parsed[i].name === root.selectedName) return
}
root.selectedName = parsed.length > 0 ? parsed[0].name : ""
}
// Mutating the array in place would not retrigger the bindings the canvas
// reads, so every edit rebuilds it.
function replaceMonitor(name, changes) {
var next = []
for (var i = 0; i < root.monitors.length; i++) {
var monitor = root.monitors[i]
if (monitor.name !== name) {
next.push(monitor)
continue
}
var copy = ({})
for (var key in monitor) copy[key] = monitor[key]
for (var change in changes) copy[change] = changes[change]
next.push(copy)
}
root.monitors = next
root.layoutDirty = true
}
function moveMonitor(name, x, y) {
var snapped = DisplayModel.snapPosition(root.monitors, name, x, y, root.snapThreshold)
root.replaceMonitor(name, { x: snapped.x, y: snapped.y })
}
function applyMonitor(name) {
for (var i = 0; i < root.monitors.length; i++) {
if (root.monitors[i].name !== name) continue
root.run("blob-display-arrange apply "
+ Util.shellQuote(DisplayModel.monitorKeyword(root.monitors[i])))
reloadTimer.restart()
return
}
}
function applyLayout() {
var keywords = DisplayModel.keywordsFor(root.monitors)
var quoted = []
for (var i = 0; i < keywords.length; i++) quoted.push(Util.shellQuote(keywords[i]))
root.run("blob-display-arrange apply " + quoted.join(" "))
root.layoutDirty = false
reloadTimer.restart()
}
function resetLayout() {
root.run("blob-display-arrange reset")
reloadTimer.restart()
}
Process {
id: monitorProc
command: ["hyprctl", "monitors", "all", "-j"]
stdout: StdioCollector { onStreamFinished: root.loadMonitors(text) }
}
Timer {
id: reloadTimer
interval: 400
onTriggered: root.refresh()
}
Timer {
interval: 4000
running: root.opened && !root.layoutDirty
repeat: true
onTriggered: root.refresh()
}
onOpenedChanged: if (root.opened) root.refresh()
PanelWindow {
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "blob-display-layout"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
exclusionMode: ExclusionMode.Ignore
MouseArea {
anchors.fill: parent
onClicked: root.dismiss()
}
Card {
anchors.centerIn: parent
implicitWidth: Style.spaceReal(720)
implicitHeight: Style.spaceReal(540)
fillColor: Util.alpha(Color.background, Style.panelFillAlpha)
spacing: Style.spaceReal(10)
Item {
width: parent.width
implicitHeight: Math.max(title.implicitHeight, closeButton.implicitHeight)
Text {
id: title
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: "Display layout"
textFormat: Text.PlainText
color: Color.foreground
font.family: Style.font.family
font.pixelSize: Style.font.body
font.bold: true
}
CardIconButton {
id: closeButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
icon: ""
onActivated: root.dismiss()
}
}
Text {
width: parent.width
wrapMode: Text.WordWrap
text: "Drag a monitor to move it. Edges snap to a neighbour. Changes apply as you drop and last until the session ends."
textFormat: Text.PlainText
color: Util.alpha(Color.foreground, 0.6)
font.family: Style.font.family
font.pixelSize: Style.font.bodySmall
}
MonitorCanvas {
width: parent.width
height: Style.spaceReal(220)
monitors: root.monitors
selectedName: root.selectedName
onSelected: function(name) { root.selectedName = name }
onMonitorMoved: function(name, x, y) { root.moveMonitor(name, x, y) }
onLayoutSettled: root.applyMonitor(root.selectedName)
}
MonitorControls {
width: parent.width
monitor: root.selectedMonitor
monitors: root.monitors
onScaleRequested: function(scale) {
root.replaceMonitor(root.selectedName, { scale: scale })
root.applyMonitor(root.selectedName)
}
onModeRequested: function(mode) {
root.replaceMonitor(root.selectedName, { mode: mode })
root.applyMonitor(root.selectedName)
}
onEnabledRequested: function(enabled) {
root.replaceMonitor(root.selectedName, { disabled: !enabled })
root.applyMonitor(root.selectedName)
}
onMirrorRequested: function(target) {
root.replaceMonitor(root.selectedName, { mirrorOf: target })
root.applyMonitor(root.selectedName)
}
}
Row {
width: parent.width
spacing: Style.spaceReal(6)
Chip {
label: root.layoutDirty ? "Apply all" : "Re-apply all"
active: root.layoutDirty
onActivated: root.applyLayout()
}
Chip {
label: "Reset to monitors.lua"
onActivated: root.resetLayout()
}
}
}
}
}
@@ -0,0 +1,176 @@
.pragma library
// hyprctl reports logical size as the raw mode divided by scale, so a 1920x1200
// panel at 1.5 occupies 1280x800 of layout space. Positions are in that same
// logical space, which is what the canvas has to draw.
function logicalSize(monitor) {
var scale = Number(monitor.scale) || 1
var width = Number(monitor.width) || 0
var height = Number(monitor.height) || 0
if (isRotated(monitor)) {
var swap = width
width = height
height = swap
}
return {
width: Math.round(width / scale),
height: Math.round(height / scale)
}
}
function isRotated(monitor) {
var transform = Number(monitor.transform) || 0
return transform === 1 || transform === 3 || transform === 5 || transform === 7
}
function parseMonitors(raw) {
var parsed
try {
parsed = JSON.parse(String(raw || "[]"))
} catch (e) {
return []
}
if (!Array.isArray(parsed)) return []
var monitors = []
for (var i = 0; i < parsed.length; i++) {
var m = parsed[i] || {}
var size = logicalSize(m)
monitors.push({
name: String(m.name || ""),
description: String(m.description || ""),
mode: modeStringOf(m),
modes: Array.isArray(m.availableModes) ? m.availableModes : [],
x: Number(m.x) || 0,
y: Number(m.y) || 0,
width: size.width,
height: size.height,
scale: Number(m.scale) || 1,
transform: Number(m.transform) || 0,
disabled: m.disabled === true,
focused: m.focused === true,
mirrorOf: String(m.mirrorOf || "none")
})
}
monitors.sort(function(a, b) { return a.x - b.x })
return monitors
}
function modeStringOf(monitor) {
var width = Number(monitor.width) || 0
var height = Number(monitor.height) || 0
var rate = Number(monitor.refreshRate) || 0
if (!width || !height) return "preferred"
return width + "x" + height + "@" + rate.toFixed(2)
}
// The canvas is a scaled-down picture of the desktop. Monitors can sit at
// negative coordinates, so the bounding box is translated to the origin before
// a single scale factor is chosen for both axes.
function layoutBounds(monitors) {
if (!monitors.length) return { x: 0, y: 0, width: 1, height: 1 }
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (var i = 0; i < monitors.length; i++) {
var m = monitors[i]
if (m.disabled) continue
minX = Math.min(minX, m.x)
minY = Math.min(minY, m.y)
maxX = Math.max(maxX, m.x + m.width)
maxY = Math.max(maxY, m.y + m.height)
}
if (minX === Infinity) return { x: 0, y: 0, width: 1, height: 1 }
return { x: minX, y: minY, width: Math.max(1, maxX - minX), height: Math.max(1, maxY - minY) }
}
function canvasScale(bounds, availableWidth, availableHeight) {
return Math.min(availableWidth / bounds.width, availableHeight / bounds.height)
}
// Snap a dragged edge to a neighbour's edge when it lands within the threshold,
// so monitors end up touching exactly rather than a few pixels apart.
function snapPosition(monitors, name, x, y, threshold) {
var snappedX = x
var snappedY = y
var self = null
for (var i = 0; i < monitors.length; i++) {
if (monitors[i].name === name) { self = monitors[i]; break }
}
if (!self) return { x: Math.round(x), y: Math.round(y) }
for (var j = 0; j < monitors.length; j++) {
var other = monitors[j]
if (other.name === name || other.disabled) continue
var candidatesX = [
other.x + other.width,
other.x - self.width,
other.x
]
for (var cx = 0; cx < candidatesX.length; cx++) {
if (Math.abs(snappedX - candidatesX[cx]) <= threshold) {
snappedX = candidatesX[cx]
break
}
}
var candidatesY = [
other.y + other.height,
other.y - self.height,
other.y
]
for (var cy = 0; cy < candidatesY.length; cy++) {
if (Math.abs(snappedY - candidatesY[cy]) <= threshold) {
snappedY = candidatesY[cy]
break
}
}
}
return { x: Math.round(snappedX), y: Math.round(snappedY) }
}
// Hyprland's keyword form: monitor = name,mode,position,scale[,transform,N]
function monitorKeyword(monitor) {
if (monitor.disabled) return monitor.name + ",disable"
var parts = [
monitor.name,
monitor.mode,
monitor.x + "x" + monitor.y,
String(monitor.scale)
]
var keyword = parts.join(",")
if (monitor.transform && monitor.transform !== 0)
keyword += ",transform," + monitor.transform
if (monitor.mirrorOf && monitor.mirrorOf !== "none")
keyword += ",mirror," + monitor.mirrorOf
return keyword
}
function keywordsFor(monitors) {
var out = []
for (var i = 0; i < monitors.length; i++) out.push(monitorKeyword(monitors[i]))
return out
}
// Hyprland rejects a fractional scale that does not land on a whole number of
// physical pixels. It steps in 1/120, so a scale is valid when both axes come
// out integral at that granularity.
function scaleIsValid(monitor, scale) {
if (!scale || scale <= 0) return false
var stepped = Math.round(scale * 120) / 120
var width = Number(monitor.width) * Number(monitor.scale)
var height = Number(monitor.height) * Number(monitor.scale)
var logicalWidth = width / stepped
var logicalHeight = height / stepped
return Math.abs(logicalWidth - Math.round(logicalWidth)) < 0.001
&& Math.abs(logicalHeight - Math.round(logicalHeight)) < 0.001
}
function nearbyScales(monitor) {
var options = []
for (var step = 60; step <= 300; step += 6) {
var candidate = step / 120
if (scaleIsValid(monitor, candidate)) options.push(candidate)
}
if (!options.length) options.push(1)
return options
}
@@ -0,0 +1,60 @@
import QtQuick
import qs.Commons
import "DisplayModel.js" as DisplayModel
Rectangle {
id: root
property var monitors: []
property string selectedName: ""
readonly property int snapThreshold: 60
signal selected(string name)
signal monitorMoved(string name, int x, int y)
signal layoutSettled()
readonly property var bounds: DisplayModel.layoutBounds(root.monitors)
readonly property real fitScale: DisplayModel.canvasScale(
bounds,
Math.max(1, width - Style.spaceReal(24)),
Math.max(1, height - Style.spaceReal(24)))
color: Util.alpha(Color.background, 0.35)
radius: Style.cardRadius
border.width: 1
border.color: Util.alpha(Color.foreground, 0.15)
clip: true
Item {
id: stage
width: root.bounds.width * root.fitScale
height: root.bounds.height * root.fitScale
anchors.centerIn: parent
Repeater {
model: root.monitors
MonitorCard {
required property var modelData
monitor: modelData
canvasScale: root.fitScale
originX: root.bounds.x
originY: root.bounds.y
selected: modelData.name === root.selectedName
onPicked: root.selected(modelData.name)
onMoved: function(x, y) { root.monitorMoved(modelData.name, x, y) }
onDropped: root.layoutSettled()
}
}
}
Text {
anchors.centerIn: parent
visible: root.monitors.length === 0
text: "No monitors reported"
textFormat: Text.PlainText
color: Util.alpha(Color.foreground, 0.6)
font.family: Style.font.family
font.pixelSize: Style.font.bodySmall
}
}
@@ -0,0 +1,94 @@
import QtQuick
import qs.Commons
Rectangle {
id: root
property var monitor: null
property bool selected: false
property real canvasScale: 1
property real originX: 0
property real originY: 0
signal picked()
signal moved(int x, int y)
signal dropped()
readonly property string name: monitor ? monitor.name : ""
readonly property bool disabled: monitor ? monitor.disabled === true : false
visible: !!monitor && !disabled
width: monitor ? Math.max(Style.spaceReal(24), monitor.width * canvasScale) : 0
height: monitor ? Math.max(Style.spaceReal(18), monitor.height * canvasScale) : 0
x: monitor ? (monitor.x - originX) * canvasScale : 0
y: monitor ? (monitor.y - originY) * canvasScale : 0
radius: Style.cardRadius
color: root.selected
? Util.alpha(Color.accent, 0.35)
: Util.alpha(Color.background, Style.cardFillAlpha)
border.width: Style.cardBorderWidth
border.color: root.selected ? Color.accent : Util.alpha(Color.blue, Style.cardBorderAlpha)
Column {
anchors.centerIn: parent
spacing: Style.spaceReal(2)
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: root.name
textFormat: Text.PlainText
color: Color.foreground
font.family: Style.font.family
font.pixelSize: Style.font.bodySmall
font.bold: true
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
visible: root.height > Style.spaceReal(44)
text: root.monitor ? root.monitor.width + "x" + root.monitor.height : ""
textFormat: Text.PlainText
color: Util.alpha(Color.foreground, 0.7)
font.family: Style.font.family
font.pixelSize: Style.font.bodySmall
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
visible: root.monitor && root.monitor.focused && root.height > Style.spaceReal(58)
text: "focused"
textFormat: Text.PlainText
color: Color.blue
font.family: Style.font.family
font.pixelSize: Style.font.bodySmall
}
}
TapHandler {
onTapped: root.picked()
}
property int dragOriginX: 0
property int dragOriginY: 0
DragHandler {
id: drag
target: null
onActiveChanged: {
if (active) {
root.picked()
root.dragOriginX = root.monitor ? root.monitor.x : 0
root.dragOriginY = root.monitor ? root.monitor.y : 0
return
}
root.dropped()
}
onTranslationChanged: {
if (!active || !root.monitor) return
root.moved(
Math.round(root.dragOriginX + translation.x / root.canvasScale),
Math.round(root.dragOriginY + translation.y / root.canvasScale))
}
}
}
@@ -0,0 +1,140 @@
import QtQuick
import qs.Commons
import qs.Ui
import "DisplayModel.js" as DisplayModel
Column {
id: root
property var monitor: null
property var monitors: []
signal scaleRequested(real scale)
signal modeRequested(string mode)
signal enabledRequested(bool enabled)
signal mirrorRequested(string target)
readonly property var scaleOptions: monitor ? DisplayModel.nearbyScales(monitor) : []
spacing: Style.spaceReal(8)
visible: !!monitor
Text {
text: root.monitor ? root.monitor.name : ""
textFormat: Text.PlainText
color: Color.foreground
font.family: Style.font.family
font.pixelSize: Style.font.body
font.bold: true
}
Text {
width: parent.width
text: root.monitor ? root.monitor.description : ""
textFormat: Text.PlainText
elide: Text.ElideRight
color: Util.alpha(Color.foreground, 0.65)
font.family: Style.font.family
font.pixelSize: Style.font.bodySmall
}
StatRow {
width: parent.width
icon: ""
label: "Position"
value: root.monitor ? root.monitor.x + ", " + root.monitor.y : ""
}
StatRow {
width: parent.width
icon: ""
label: "Logical size"
value: root.monitor ? root.monitor.width + "x" + root.monitor.height : ""
}
Text {
text: "Mode"
textFormat: Text.PlainText
color: Util.alpha(Color.foreground, 0.75)
font.family: Style.font.family
font.pixelSize: Style.font.bodySmall
}
Flow {
width: parent.width
spacing: Style.spaceReal(4)
Repeater {
model: root.monitor ? root.monitor.modes : []
Chip {
required property var modelData
label: String(modelData).replace("Hz", "")
active: root.monitor && String(modelData).indexOf(root.monitor.mode) === 0
onActivated: root.modeRequested(String(modelData).replace("Hz", ""))
}
}
}
Text {
text: "Scale"
textFormat: Text.PlainText
color: Util.alpha(Color.foreground, 0.75)
font.family: Style.font.family
font.pixelSize: Style.font.bodySmall
}
Flow {
width: parent.width
spacing: Style.spaceReal(4)
Repeater {
model: root.scaleOptions
Chip {
required property var modelData
label: String(modelData)
active: root.monitor && Math.abs(root.monitor.scale - modelData) < 0.001
onActivated: root.scaleRequested(Number(modelData))
}
}
}
Text {
width: parent.width
wrapMode: Text.WordWrap
text: "Only scales that land on whole pixels are offered. Hyprland steps in 1/120 and rejects the rest."
textFormat: Text.PlainText
color: Util.alpha(Color.foreground, 0.55)
font.family: Style.font.family
font.pixelSize: Style.font.bodySmall
}
Row {
spacing: Style.spaceReal(6)
Chip {
label: root.monitor && root.monitor.disabled ? "Enable" : "Disable"
onActivated: root.enabledRequested(root.monitor ? root.monitor.disabled === true : true)
}
Chip {
label: root.monitor && root.monitor.mirrorOf !== "none" ? "Unmirror" : "Mirror"
active: root.monitor && root.monitor.mirrorOf !== "none"
enabled: root.monitors.length > 1
onActivated: {
if (!root.monitor) return
if (root.monitor.mirrorOf !== "none") {
root.mirrorRequested("none")
return
}
for (var i = 0; i < root.monitors.length; i++) {
if (root.monitors[i].name !== root.monitor.name) {
root.mirrorRequested(root.monitors[i].name)
return
}
}
}
}
}
}
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"name": "Display layout",
"version": "1.0.0",
"author": "Blob",
"description": "Arrange monitors by dragging, and set their mode, scale, and mirroring",
"id": "blob.display-layout",
"kinds": [
"overlay"
],
"keepLoaded": true,
"entryPoints": {
"overlay": "DisplayLayout.qml"
}
}
+217
View File
@@ -0,0 +1,217 @@
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs.Commons
import qs.Ui
Item {
id: root
property var shell: null
property var manifest: null
property bool opened: false
readonly property string pluginId: (manifest && manifest.id) || "blob.displays"
property var outputs: []
property string selectedName: ""
property var assignments: ({})
readonly property bool globalMode: root.selectedName === ""
readonly property string assignedPath: root.globalMode
? "" : String(root.assignments[root.selectedName] || "")
function open(payloadJson) {
root.opened = true
root.refresh()
}
function close() {
root.opened = false
}
function dismiss() {
root.opened = false
if (root.shell && typeof root.shell.hide === "function")
root.shell.hide(root.pluginId)
}
function run(command) {
Quickshell.execDetached(["bash", "-c", command])
}
function refresh() {
if (!outputProc.running) outputProc.running = true
if (!assignmentProc.running) assignmentProc.running = true
}
function loadOutputs(raw) {
var names = []
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (line.length > 0) names.push(line)
}
root.outputs = names
}
function loadAssignments(raw) {
var next = ({})
var lines = String(raw || "").split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (!line) continue
var split = line.indexOf("\t")
if (split <= 0) continue
next[line.substring(0, split)] = line.substring(split + 1)
}
root.assignments = next
}
function choose(path) {
if (root.globalMode) {
root.run("blob-bg-set " + Util.shellQuote(path))
return
}
root.run("blob-bg-monitor " + Util.shellQuote(root.selectedName)
+ " " + Util.shellQuote(path))
var next = ({})
for (var key in root.assignments) next[key] = root.assignments[key]
next[root.selectedName] = path
root.assignments = next
}
function clearAssignment() {
if (root.globalMode) return
root.run("blob-bg-monitor " + Util.shellQuote(root.selectedName) + " --clear")
var next = ({})
for (var key in root.assignments)
if (key !== root.selectedName) next[key] = root.assignments[key]
root.assignments = next
}
Process {
id: outputProc
command: ["bash", "-c", "hyprctl monitors -j | jq -r '.[].name'"]
stdout: StdioCollector { onStreamFinished: root.loadOutputs(text) }
}
Process {
id: assignmentProc
command: ["bash", "-c",
'dir="$HOME/.local/state/blob/backgrounds"; [[ -d $dir ]] || exit 0; ' +
'for link in "$dir"/*; do [[ -e $link ]] || continue; ' +
'printf "%s\\t%s\\n" "${link##*/}" "$(readlink -f "$link")"; done']
stdout: StdioCollector { onStreamFinished: root.loadAssignments(text) }
}
onOpenedChanged: if (root.opened) root.refresh()
PanelWindow {
visible: root.opened
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
WlrLayershell.namespace: "blob-wallpapers"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.OnDemand
exclusionMode: ExclusionMode.Ignore
MouseArea {
anchors.fill: parent
onClicked: root.dismiss()
}
Card {
anchors.centerIn: parent
implicitWidth: Style.spaceReal(720)
implicitHeight: Style.spaceReal(520)
fillColor: Util.alpha(Color.background, Style.panelFillAlpha)
spacing: Style.spaceReal(10)
Item {
width: parent.width
implicitHeight: Math.max(title.implicitHeight, closeButton.implicitHeight)
Text {
id: title
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: "Wallpapers"
textFormat: Text.PlainText
color: Color.foreground
font.family: Style.font.family
font.pixelSize: Style.font.body
font.bold: true
}
CardIconButton {
id: closeButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
icon: ""
onActivated: root.dismiss()
}
}
Flow {
width: parent.width
spacing: Style.spaceReal(4)
Chip {
label: "All monitors"
active: root.globalMode
onActivated: root.selectedName = ""
}
Repeater {
model: root.outputs
Chip {
required property var modelData
label: root.assignments[modelData]
? String(modelData) + " *" : String(modelData)
active: root.selectedName === modelData
onActivated: root.selectedName = String(modelData)
}
}
}
Item {
width: parent.width
implicitHeight: status.implicitHeight
Text {
id: status
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: root.globalMode
? "Setting the wallpaper for every monitor without its own"
: (root.assignedPath.length > 0
? root.selectedName + ": " + root.assignedPath.replace(/^.*\//, "")
: root.selectedName + ": following the global wallpaper")
textFormat: Text.PlainText
color: Util.alpha(Color.foreground, 0.7)
font.family: Style.font.family
font.pixelSize: Style.font.bodySmall
}
Chip {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
label: "Clear"
enabled: !root.globalMode && root.assignedPath.length > 0
onActivated: root.clearAssignment()
}
}
WallpaperGrid {
width: parent.width
height: Style.spaceReal(340)
polling: root.opened
assignedPath: root.globalMode ? "" : root.assignedPath
onChosen: function(path) { root.choose(path) }
}
}
}
}
@@ -0,0 +1,119 @@
import QtQuick
import Quickshell
import Quickshell.Io
import qs.Commons
Item {
id: root
property string directory: Quickshell.env("HOME") + "/wallpapers"
property string assignedPath: ""
property bool polling: false
property var wallpapers: []
signal chosen(string path)
function refresh() {
if (!listProc.running) listProc.running = true
}
Process {
id: listProc
command: ["bash", "-c",
'find -L "$1" -maxdepth 1 -type f \\( -iname "*.jpg" -o -iname "*.jpeg" ' +
'-o -iname "*.png" -o -iname "*.webp" -o -iname "*.gif" \\) | sort',
"--", root.directory]
stdout: StdioCollector {
onStreamFinished: {
var out = []
var lines = String(text).split("\n")
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim()
if (line.length > 0) out.push(line)
}
root.wallpapers = out
}
}
}
onPollingChanged: if (root.polling && root.wallpapers.length === 0) root.refresh()
Component.onCompleted: root.refresh()
GridView {
id: grid
anchors.fill: parent
clip: true
cellWidth: Style.spaceReal(120)
cellHeight: Style.spaceReal(80)
model: root.wallpapers
delegate: Item {
required property var modelData
width: grid.cellWidth
height: grid.cellHeight
Rectangle {
anchors.fill: parent
anchors.margins: Style.spaceReal(3)
radius: Style.cardRadius
color: Util.alpha(Color.background, Style.cardFillAlpha)
border.width: Style.cardBorderWidth
border.color: modelData === root.assignedPath
? Color.accent
: (hover.hovered ? Color.blue : Util.alpha(Color.blue, Style.cardBorderAlpha))
clip: true
Image {
anchors.fill: parent
anchors.margins: Style.cardBorderWidth
source: Util.fileUrl(modelData)
fillMode: Image.PreserveAspectCrop
asynchronous: true
cache: true
sourceSize.width: Math.round(Style.spaceReal(120) * 1.5)
}
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Style.cardBorderWidth
height: caption.implicitHeight + Style.spaceReal(4)
visible: hover.hovered || modelData === root.assignedPath
color: Util.alpha(Color.background, 0.8)
Text {
id: caption
anchors.centerIn: parent
width: parent.width - Style.spaceReal(6)
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideMiddle
text: String(modelData).replace(/^.*\//, "")
textFormat: Text.PlainText
color: Color.foreground
font.family: Style.font.family
font.pixelSize: Style.font.bodySmall
}
}
HoverHandler {
id: hover
}
TapHandler {
onTapped: root.chosen(String(modelData))
}
}
}
}
Text {
anchors.centerIn: parent
visible: root.wallpapers.length === 0
text: "No images in " + root.directory
textFormat: Text.PlainText
color: Util.alpha(Color.foreground, 0.6)
font.family: Style.font.family
font.pixelSize: Style.font.bodySmall
}
}
@@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"name": "Wallpapers",
"version": "1.0.0",
"author": "Blob",
"description": "Pick wallpapers, globally or per monitor",
"id": "blob.displays",
"kinds": [
"overlay"
],
"keepLoaded": true,
"entryPoints": {
"overlay": "Displays.qml"
}
}
@@ -15,6 +15,13 @@ Item {
readonly property string pluginId: (manifest && manifest.id) || "blob.quick-settings"
readonly property int barInset: {
var bar = root.shell ? root.shell.bar : null
if (!bar || bar.barHidden === true) return 0
if (String(bar.position || "top") !== "top") return 0
return Math.max(0, bar.barSize || 0)
}
function open(payloadJson) {
root.opened = true
}
@@ -185,83 +192,82 @@ Item {
onClicked: root.dismiss()
}
Row {
WeatherCard {
anchors.top: controlCentre.top
anchors.right: controlCentre.left
anchors.rightMargin: Style.spaceReal(12)
available: root.weatherAvailable
glyph: root.weatherGlyph
place: root.weatherPlace
temperature: root.weatherTemperature
wind: root.weatherWind
}
Card {
id: controlCentre
anchors.top: parent.top
anchors.topMargin: Style.spaceReal(10)
anchors.topMargin: Style.spaceReal(10) + root.barInset
anchors.horizontalCenter: parent.horizontalCenter
implicitWidth: Style.spaceReal(400)
fillColor: Util.alpha(Color.background, Style.panelFillAlpha)
spacing: Style.spaceReal(12)
WeatherCard {
available: root.weatherAvailable
glyph: root.weatherGlyph
place: root.weatherPlace
temperature: root.weatherTemperature
wind: root.weatherWind
Header {
width: parent.width
clock: clockValue.value
today: dateValue.value
onLockRequested: root.runAndDismiss("blob-system-lock")
onLogoutRequested: root.runAndDismiss("blob-system-logout")
onCloseRequested: root.dismiss()
}
Card {
id: controlCentre
implicitWidth: Style.spaceReal(400)
fillColor: Util.alpha(Color.background, Style.panelFillAlpha)
spacing: Style.spaceReal(12)
Calendar {
width: parent.width
polling: root.opened
}
Header {
width: parent.width
clock: clockValue.value
today: dateValue.value
onLockRequested: root.runAndDismiss("blob-system-lock")
onLogoutRequested: root.runAndDismiss("blob-system-logout")
onCloseRequested: root.dismiss()
Tiles {
bluetoothOn: bluetoothFlag.value
doNotDisturb: dndFlag.value
nightLight: nightLightFlag.value
recording: recordingFlag.value
stayAwake: stayAwakeFlag.value
tabletFollow: tabletFlag.value
onRunRequested: function(command) { root.runAndDismiss(command) }
onPanelRequested: function(pluginId) { root.openPanel(pluginId) }
onToggleRequested: function(command) { root.toggleAndRefresh(command) }
}
SliderRow {
width: parent.width
icon: mutedFlag.value ? "" : ""
dimmed: mutedFlag.value
value: Number(volumeValue.value) || 0
onIconActivated: root.toggleAndRefresh("pamixer --toggle-mute")
onValueRequested: function(next) {
root.run("pamixer --set-volume " + next)
volumeValue.value = String(next)
}
}
Calendar {
width: parent.width
polling: root.opened
SliderRow {
width: parent.width
icon: ""
value: Number(brightnessValue.value) || 0
onValueRequested: function(next) {
root.run("brightnessctl set " + next + "%")
brightnessValue.value = String(next)
}
}
Tiles {
bluetoothOn: bluetoothFlag.value
doNotDisturb: dndFlag.value
nightLight: nightLightFlag.value
recording: recordingFlag.value
stayAwake: stayAwakeFlag.value
tabletFollow: tabletFlag.value
onRunRequested: function(command) { root.runAndDismiss(command) }
onPanelRequested: function(pluginId) { root.openPanel(pluginId) }
onToggleRequested: function(command) { root.toggleAndRefresh(command) }
}
SliderRow {
width: parent.width
icon: mutedFlag.value ? "" : ""
dimmed: mutedFlag.value
value: Number(volumeValue.value) || 0
onIconActivated: root.toggleAndRefresh("pamixer --toggle-mute")
onValueRequested: function(next) {
root.run("pamixer --set-volume " + next)
volumeValue.value = String(next)
}
}
SliderRow {
width: parent.width
icon: ""
value: Number(brightnessValue.value) || 0
onValueRequested: function(next) {
root.run("brightnessctl set " + next + "%")
brightnessValue.value = String(next)
}
}
MediaRow {
width: parent.width
title: root.mediaParts.length > 0 ? root.mediaParts[0] : ""
artist: root.mediaParts.length > 1 ? root.mediaParts[1] : ""
status: root.mediaParts.length > 2 ? root.mediaParts[2] : "Stopped"
onControlRequested: function(action) {
root.run("playerctl " + action)
stateSettleTimer.restart()
}
MediaRow {
width: parent.width
title: root.mediaParts.length > 0 ? root.mediaParts[0] : ""
artist: root.mediaParts.length > 1 ? root.mediaParts[1] : ""
status: root.mediaParts.length > 2 ? root.mediaParts[2] : "Stopped"
onControlRequested: function(action) {
root.run("playerctl " + action)
stateSettleTimer.restart()
}
}
}
+30 -22
View File
@@ -1,23 +1,31 @@
accent = "#7babe3"
cursor = "#cbd2d9"
foreground = "#cbd2d9"
background = "#0f1012"
selection_foreground = "#0f1012"
selection_background = "#7babe3"
mode = "dark"
color0 = "#000000"
color1 = "#d53838"
color2 = "#38d4d5"
color3 = "#d58638"
color4 = "#d53886"
color5 = "#38d586"
color6 = "#3886d5"
color7 = "#d4d4d4"
color8 = "#949494"
color9 = "#d53838"
color10 = "#38d4d5"
color11 = "#d58638"
color12 = "#d53886"
color13 = "#38d586"
color14 = "#3886d5"
color15 = "#d4d4d4"
accent = "#5fa3e8"
selection = "#1e2228"
muted = "#5c656e"
background = "#0f1012"
dark_background = "#0a0b0c"
darker_background = "#050607"
lighter_background = "#191c20"
foreground = "#cbd2d9"
dark_foreground = "#78828c"
light_foreground = "#dde3e9"
bright_foreground = "#f0f4f8"
red = "#d53838"
yellow = "#d58638"
orange = "#d56338"
green = "#38d586"
cyan = "#38d4d5"
blue = "#3886d5"
magenta = "#d53886"
brown = "#6a321c"
bright_red = "#dd6060"
bright_yellow = "#dd9e60"
bright_green = "#60dd9e"
bright_cyan = "#60dddd"
bright_blue = "#609edd"
bright_magenta = "#dd609e"
+64 -2
View File
@@ -4,9 +4,35 @@ 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/profile.d/blob.sh
/usr/local/bin/blob-hw-gpu-accelerated
/etc/pam.d/blob-lock-password
/etc/sddm.conf.d/zz-blob.conf
/etc/sddm.conf.d/zzz-blob-autologin.conf
/etc/sddm/hyprland-greeter.conf
/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 +42,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 +52,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 +69,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
@@ -67,6 +95,11 @@ if [[ -L $blob_path ]]; then
say "remove BLOB_PATH symlink"
fi
if [[ -L $HOME/wallpapers ]]; then
rm -f "$HOME/wallpapers"
say "remove ~/wallpapers symlink"
fi
for relative in blob/shell.json uwsm/default uwsm/env.d/10-blob; do
restore_or_remove "$config_dir/$relative" "$relative"
done
@@ -80,11 +113,40 @@ for lua in hyprland.lua monitors.lua input.lua looknfeel.lua bindings.lua autost
restore_or_remove "$config_dir/hypr/$lua" "hypr/$lua"
done
[[ -L $config_dir/starship.toml ]] && restore_or_remove "$config_dir/starship.toml" "starship.toml"
[[ -L $config_dir/fastfetch/config.jsonc ]] && restore_or_remove "$config_dir/fastfetch/config.jsonc" "fastfetch/config.jsonc"
for gtk in gtk-3.0 gtk-4.0; do
[[ -L $config_dir/$gtk/gtk.css ]] && restore_or_remove "$config_dir/$gtk/gtk.css" "$gtk/gtk.css"
done
if [[ $keep_state == false && -d $HOME/.local/state/blob ]]; then
rm -rf "$HOME/.local/state/blob"
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"