diff --git a/AGENTS.md b/AGENTS.md index 23897a1b..8a8da1c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -262,7 +262,7 @@ This copies `$OMARCHY_PATH/config/hypr/hyprlock.conf` to `~/.config/hypr/hyprloc Read `docs/migrations.md` before creating or changing migrations. -Migrations are per-user and run through `omarchy-migrate` during `omarchy update` or from the migration notification. Put migrations directly under `migrations/.sh`. Pending state is per-user under `~/.local/state/omarchy/migrations/`, so every user gets a chance to run every migration. Migrations run as the user; privileged work should invoke the appropriate helper or privilege prompt, and no-op when another user already applied it. +Migrations are per-user and run through `omarchy-migrate` during `omarchy update` or from the login-time migration notification. Put migrations directly under `migrations/.sh`. Pending state is per-user under `~/.local/state/omarchy/migrations/`, so every user gets a chance to run every migration. Migrations run as the user; privileged work should invoke the appropriate helper or privilege prompt, and no-op when another user already applied it. To create a new migration, run `omarchy-dev-add-migration --no-edit`. diff --git a/bin/omarchy b/bin/omarchy index 0f50ccc7..57910d51 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -45,6 +45,7 @@ GROUP_DESCRIPTIONS[dev]="Omarchy development tools" GROUP_DESCRIPTIONS[display]="Display and text scaling" GROUP_DESCRIPTIONS[dns]="DNS resolver configuration" GROUP_DESCRIPTIONS[drive]="Drive selection and encryption" +GROUP_DESCRIPTIONS[file]="File selection helpers" GROUP_DESCRIPTIONS[font]="Font management" GROUP_DESCRIPTIONS[games]="Game launchers and helpers" GROUP_DESCRIPTIONS[hibernation]="Hibernation setup and removal" @@ -78,6 +79,7 @@ GROUP_DESCRIPTIONS[snapshot]="System snapshots" GROUP_DESCRIPTIONS[style]="Global UI style controls" GROUP_DESCRIPTIONS[sudo]="Sudo configuration helpers" GROUP_DESCRIPTIONS[system]="System status, reboot, shutdown, logout, and lock" +GROUP_DESCRIPTIONS[tailscale]="Tailscale helpers" GROUP_DESCRIPTIONS[theme]="Theme management" GROUP_DESCRIPTIONS[tmux]="Tmux session helpers" GROUP_DESCRIPTIONS[toggle]="Toggle Omarchy features" diff --git a/bin/omarchy-bar-plugin b/bin/omarchy-bar-plugin index c7442f10..58c89692 100755 --- a/bin/omarchy-bar-plugin +++ b/bin/omarchy-bar-plugin @@ -288,6 +288,15 @@ cmd_move() { fi local default_section="${PLACEMENT_SECTION:-}" + + # A bare section names the section, not the slot, so let it fall through to + # the same anchor placement 'add' uses. Passing it as an explicit target + # instead drops the widget on the far end of the row. + local target_section="$PLACEMENT_SECTION" + if [[ -z $PLACEMENT_INDEX && -z $PLACEMENT_BEFORE && -z $PLACEMENT_AFTER ]]; then + target_section="" + fi + local prog prog=$(cat </dev/null & - sleep 1 + + # The move has to settle before gpu-screen-recorder starts, or the camera is + # recorded sliding into its corner. Waiting for the map is what the blind + # second was partly guessing at, so the remainder is trimmed to hold the + # pre-capture delay where it was: starting later costs the first words spoken. + local waited=0 + while ((waited < 40)) && ! hyprctl clients -j | jq -e 'any(.[]; .title == "WebcamOverlay")' >/dev/null 2>&1; do + sleep 0.05 + ((waited++)) + done + + [[ ${1:-} == region:* ]] && echo "${1#region:}" >"$REGION_FILE" + omarchy-capture-webcam-resize "$WEBCAM_SIZE" + + sleep 0.6 } cleanup_webcam() { pkill -f "WebcamOverlay" 2>/dev/null + rm -f "$REGION_FILE" } default_resolution() { @@ -155,7 +171,7 @@ start_screenrecording() { esac fi - [[ $WEBCAM == "true" ]] && start_webcam_overlay + [[ $WEBCAM == "true" ]] && start_webcam_overlay "$target" local filename="$OUTPUT_DIR/screenrecording-$(date +'%Y-%m-%d_%H-%M-%S').mp4" local audio_devices="" diff --git a/bin/omarchy-capture-webcam-resize b/bin/omarchy-capture-webcam-resize index ebe9840f..cde61e08 100755 --- a/bin/omarchy-capture-webcam-resize +++ b/bin/omarchy-capture-webcam-resize @@ -8,6 +8,7 @@ set -euo pipefail readonly MARGIN=40 +readonly REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region" usage() { echo "Usage: omarchy-capture-webcam-resize " >&2 @@ -57,13 +58,39 @@ read -r monitor_x monitor_y monitor_width monitor_height < <( [[ $monitor_x =~ ^-?[0-9]+$ && $monitor_y =~ ^-?[0-9]+$ && $monitor_width =~ ^[0-9]+$ && $monitor_height =~ ^[0-9]+$ ]] || exit 0 -# Scale the 8:9 portrait presets from monitor height so they occupy the same +# Anchor to the recorded region when there is one, so a window picked on a wide +# display keeps the camera in its own corner. Full-monitor captures, the portal +# backend, and resizes outside a recording publish none and fall back here. +anchor_x=$monitor_x +anchor_y=$monitor_y +anchor_width=$monitor_width +anchor_height=$monitor_height + +if [[ -f $REGION_FILE ]] && region=$(<"$REGION_FILE"); then + if [[ $region =~ ^([0-9]+)x([0-9]+)\+(-?[0-9]+)\+(-?[0-9]+)$ ]]; then + anchor_width=${BASH_REMATCH[1]} + anchor_height=${BASH_REMATCH[2]} + anchor_x=${BASH_REMATCH[3]} + anchor_y=${BASH_REMATCH[4]} + fi +fi + +# A tall, narrow region can't fit presets scaled from its own height, so cap the +# height they scale from to what the width allows — the large preset is the +# widest at 3/10 of it. Scaling the ladder as a whole leaves small, medium and +# large distinct sizes for smaller and larger to step between. +scale_height=$anchor_height +available_width=$((anchor_width - 2 * MARGIN)) +((available_width > 0 && scale_height * 3 / 10 > available_width)) && + scale_height=$((available_width * 10 / 3)) + +# Scale the 8:9 portrait presets from that height so they occupy the same # proportion of a 1080p, HiDPI, ultrawide, or 6K recording. -small_height=$(((monitor_height * 9 + 25) / 50)) +small_height=$(((scale_height * 9 + 25) / 50)) small_width=$(((small_height * 8 + 4) / 9)) -medium_height=$(((monitor_height + 2) / 4)) +medium_height=$(((scale_height + 2) / 4)) medium_width=$(((medium_height * 8 + 4) / 9)) -large_height=$(((monitor_height * 27 + 40) / 80)) +large_height=$(((scale_height * 27 + 40) / 80)) large_width=$(((large_height * 8 + 4) / 9)) target_width=$current_width @@ -107,11 +134,11 @@ larger) ;; esac -target_x=$((monitor_x + monitor_width - target_width - MARGIN)) -target_y=$((monitor_y + monitor_height - target_height - MARGIN)) +target_x=$((anchor_x + anchor_width - target_width - MARGIN)) +target_y=$((anchor_y + anchor_height - target_height - MARGIN)) -((target_x < monitor_x + MARGIN)) && target_x=$((monitor_x + MARGIN)) -((target_y < monitor_y + MARGIN)) && target_y=$((monitor_y + MARGIN)) +((target_x < anchor_x + MARGIN)) && target_x=$((anchor_x + MARGIN)) +((target_y < anchor_y + MARGIN)) && target_y=$((anchor_y + MARGIN)) window="address:$address" hypr_dispatch \ diff --git a/bin/omarchy-file-select b/bin/omarchy-file-select new file mode 100755 index 00000000..6b0fab12 --- /dev/null +++ b/bin/omarchy-file-select @@ -0,0 +1,107 @@ +#!/usr/bin/python3 + +# omarchy:summary=Pick files with the desktop file chooser +# omarchy:args=[--title ] [--multiple] +# omarchy:examples=omarchy file select --title "Send with Tailscale" --multiple + +# Python rather than bash, alone among the commands here, because the portal +# answers a request with a Response signal addressed to the connection that +# asked, and D-Bus delivers a directed signal only to that connection. Every +# shell-callable client — gdbus call, busctl call, dbus-send — opens its own +# connection and exits before the answer arrives, and gdbus monitor registers +# with AddMatch rather than BecomeMonitor, so it never sees one either. Holding +# a single connection across both the call and the wait is the whole job, and +# bash has no way to hold one. + +import argparse +import os +import sys + +import gi + +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib + +# A dialog nobody ever answers would otherwise keep this process, and whatever +# waits on its output, alive forever. +ANSWER_TIMEOUT_SEC = 600 + +# Callers act on these: nothing picked is a decision, a chooser that never ran +# is a fault, and the two want different handling. +EXIT_NOTHING_PICKED = 1 +EXIT_CHOOSER_FAILED = 2 + + +def main(): + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--title", default="Select file") + parser.add_argument("--multiple", action="store_true") + args, unknown = parser.parse_known_args() + + if unknown: + print("omarchy-file-select: unknown option %s" % unknown[0], file=sys.stderr) + return EXIT_CHOOSER_FAILED + + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + loop = GLib.MainLoop() + uris = [] + + def on_response(connection, sender, path, interface, signal, params): + code, results = params.unpack() + if code == 0: + uris.extend(results.get("uris", [])) + loop.quit() + + def subscribe(path): + bus.signal_subscribe( + "org.freedesktop.portal.Desktop", + "org.freedesktop.portal.Request", + "Response", + path, + None, + Gio.DBusSignalFlags.NONE, + on_response, + ) + + # The request path is derived from our bus name and the token we pass, so it + # can be subscribed to up front. Asking first would race a dialog that gets + # answered immediately. + token = "omarchy%d" % os.getpid() + sender = bus.get_unique_name()[1:].replace(".", "_") + predicted = "/org/freedesktop/portal/desktop/request/%s/%s" % (sender, token) + subscribe(predicted) + + handle = bus.call_sync( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.FileChooser", + "OpenFile", + GLib.Variant("(ssa{sv})", ("", args.title, { + "handle_token": GLib.Variant("s", token), + "multiple": GLib.Variant("b", args.multiple), + })), + None, + Gio.DBusCallFlags.NONE, + -1, + None, + ).unpack()[0] + + # Portals predating the token convention answer on a path of their choosing. + if handle != predicted: + subscribe(handle) + + GLib.timeout_add_seconds(ANSWER_TIMEOUT_SEC, loop.quit) + loop.run() + + for uri in uris: + print(GLib.filename_from_uri(uri)[0]) + + return 0 if uris else EXIT_NOTHING_PICKED + + +if __name__ == "__main__": + try: + sys.exit(main()) + except GLib.Error as error: + print("omarchy-file-select: %s" % error.message, file=sys.stderr) + sys.exit(EXIT_CHOOSER_FAILED) diff --git a/bin/omarchy-first-run b/bin/omarchy-first-run index c31bd30c..a771a9c1 100755 --- a/bin/omarchy-first-run +++ b/bin/omarchy-first-run @@ -53,37 +53,6 @@ log_first_run() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >>"$FIRST_RUN_LOG" } -notification_server_ready() { - if omarchy-cmd-present gdbus; then - gdbus call --session \ - --dest org.freedesktop.Notifications \ - --object-path /org/freedesktop/Notifications \ - --method org.freedesktop.Notifications.GetServerInformation >/dev/null 2>&1 - elif omarchy-cmd-present busctl; then - busctl --user call \ - org.freedesktop.Notifications \ - /org/freedesktop/Notifications \ - org.freedesktop.Notifications \ - GetServerInformation >/dev/null 2>&1 - else - return 0 - fi -} - -wait_for_notifications() { - omarchy-cmd-present omarchy-shell || return 0 - - for _ in {1..100}; do - if omarchy-shell notifications ping >/dev/null 2>&1 && notification_server_ready; then - return 0 - fi - sleep 0.1 - done - - log_first_run "Timed out waiting for notification service; continuing" - return 0 -} - run_first_run_step() { local name="$1" shift @@ -98,12 +67,6 @@ run_first_run_step() { fi } -wait_for_notifications - -run_first_run_step "enable migration notification watcher" \ - systemctl --user enable --now omarchy-update-user-notify.path -run_first_run_step "notify about pending migrations" omarchy-migrate-notify - run_first_run_step "install Voxtype post-update hook" \ omarchy-hook-install post-update "$OMARCHY_PATH/install/user/first-run/install-voxtype.hook" run_first_run_step "install fingerprint setup post-update hook" \ @@ -118,7 +81,7 @@ run_first_run_step "set GTK primary paste" \ run_first_run_step "apply speaker tuning" \ bash "$OMARCHY_PATH/install/user/first-run/audio-tuning.sh" -wait_for_notifications +omarchy-notification-wait || log_first_run "Timed out waiting for notification service; continuing" run_first_run_step "show welcome notification" \ bash "$OMARCHY_PATH/install/user/first-run/welcome.sh" # The first-run notification scripts register action callbacks in background diff --git a/bin/omarchy-install-service-tailscale b/bin/omarchy-install-service-tailscale index 8adffc53..58dd28b5 100755 --- a/bin/omarchy-install-service-tailscale +++ b/bin/omarchy-install-service-tailscale @@ -13,6 +13,9 @@ sudo tailscale up --accept-routes echo -e "\nAllowing $USER to manage Tailscale..." sudo tailscale set --operator="$USER" +echo -e "\nReceiving Taildrop files in $HOME/Downloads..." +systemctl --user enable --now omarchy-tailscale-receive.service + echo -e "\nAdding Tailscale to the bar..." omarchy-bar-plugin add omarchy.tailscale diff --git a/bin/omarchy-migrate b/bin/omarchy-migrate index fc801e35..056a578d 100755 --- a/bin/omarchy-migrate +++ b/bin/omarchy-migrate @@ -96,6 +96,7 @@ while IFS=$'\t' read -r name file marker; do fi done < <(migration_entries) -# Clear notifications queued while an update was installing migrations. The -# substring matches both the current and legacy notification titles. +# Clear a login-time notification the user left sitting there and then resolved +# by running migrations some other way. The substring matches both the current +# and legacy notification titles. omarchy-notification-dismiss "Omarchy Migrations" >/dev/null 2>&1 || true diff --git a/bin/omarchy-migrate-notify b/bin/omarchy-migrate-notify index 8f7935b8..1f9fb541 100755 --- a/bin/omarchy-migrate-notify +++ b/bin/omarchy-migrate-notify @@ -15,11 +15,17 @@ fi notify_command=$(printf 'if [[ -n $(omarchy-notification-send -u critical -g  "Pending Omarchy Migrations" %q -a) ]]; then omarchy-launch-floating-terminal-with-presentation omarchy-migrate; fi' "$message") -if omarchy-cmd-present systemd-run; then - unit="omarchy-migrations-notification-$(date +%Y%m%d%H%M%S)" - systemd-run --user --scope --unit="$unit" bash -lc "$notify_command" >/dev/null 2>&1 && exit 0 -fi +# This runs from omarchy-migrate-notify.service at graphical-session.target, +# which the session can reach before the shell has claimed +# org.freedesktop.Notifications. Without the wait the toast is sent into the +# void and the user never learns about their pending migrations. +omarchy-notification-wait || true +unit="omarchy-migrations-notification-$(date +%Y%m%d%H%M%S)" +systemd-run --user --scope --unit="$unit" bash -lc "$notify_command" >/dev/null 2>&1 && exit 0 + +# Reached when there is no user manager to run the scope under, such as a +# non-graphical shell, so fall back to telling the user in the terminal. print_pending_migrations() { echo "Omarchy has pending migrations. Run omarchy-migrate in a terminal to apply them:" while IFS= read -r migration; do diff --git a/bin/omarchy-notification-wait b/bin/omarchy-notification-wait new file mode 100755 index 00000000..609dc326 --- /dev/null +++ b/bin/omarchy-notification-wait @@ -0,0 +1,30 @@ +#!/bin/bash + +# omarchy:summary=Wait for the desktop notification server to accept notifications +# omarchy:args=[timeout-seconds] +# omarchy:hidden=true + +set -uo pipefail + +timeout=${1:-10} + +notification_server_ready() { + busctl --user call \ + org.freedesktop.Notifications \ + /org/freedesktop/Notifications \ + org.freedesktop.Notifications \ + GetServerInformation >/dev/null 2>&1 +} + +# The shell has to be up to serve the IPC, and it has to have claimed the +# notification bus name before notify-send has anywhere to deliver. +attempts=$((timeout * 10)) +while (( attempts > 0 )); do + if omarchy-shell notifications ping >/dev/null 2>&1 && notification_server_ready; then + exit 0 + fi + attempts=$((attempts - 1)) + sleep 0.1 +done + +exit 1 diff --git a/bin/omarchy-remove-service-tailscale b/bin/omarchy-remove-service-tailscale index acda106e..adbe2103 100644 --- a/bin/omarchy-remove-service-tailscale +++ b/bin/omarchy-remove-service-tailscale @@ -4,6 +4,7 @@ # omarchy:requires-sudo=true tailscale down 2>/dev/null || true +systemctl --user disable --now omarchy-tailscale-receive.service 2>/dev/null || true sudo systemctl disable --now tailscaled.service 2>/dev/null || true omarchy-bar-plugin remove omarchy.tailscale omarchy-webapp-remove "Tailscale" 2>/dev/null || true diff --git a/bin/omarchy-tailscale-receive b/bin/omarchy-tailscale-receive new file mode 100755 index 00000000..6232646e --- /dev/null +++ b/bin/omarchy-tailscale-receive @@ -0,0 +1,101 @@ +#!/bin/bash + +# omarchy:summary=Save incoming Taildrop files and announce them +# omarchy:args=[--once] [directory] +# omarchy:examples=omarchy tailscale receive | omarchy tailscale receive --once ~/Desktop + +set -euo pipefail + +once=false +if [[ ${1:-} == "--once" ]]; then + once=true + shift +fi + +dir="${1:-${XDG_DOWNLOAD_DIR:-$HOME/Downloads}}" + +# Taildrop lands in a staging directory next door rather than straight in the +# downloads directory: waiting for a delivery can take hours, and everything +# else that shows up meanwhile is somebody else's file. Same filesystem, so +# handing the finished file over is a rename. +staging="$dir/.omarchy-taildrop" +mkdir -p "$staging" + +# Take the name by linking to it rather than by looking and then renaming. +# link(2) refuses an existing name, so nothing can land on the chosen one in +# the gap between the two. Staging shares the filesystem with the downloads +# directory, so the link always resolves and unlinking the staged name +# finishes the move. Prints the name it took. +claim_path() { + local staged="$1" name="${staged##*/}" base ext candidate index=0 + + base="${name%.*}" + ext="${name#"$base"}" + [[ -z $base ]] && { base="$name"; ext=""; } + + while (( index < 1000 )); do + if (( index == 0 )); then + candidate="$dir/$name" + else + candidate="$dir/$base-$index$ext" + fi + + if ln -- "$staged" "$candidate" 2>/dev/null; then + rm -f -- "$staged" + printf '%s\n' "$candidate" + return 0 + fi + + # Only a taken name is worth another spin. Anything else failed the link + # itself, and the file keeps its place in staging for the next run. + [[ -e $candidate ]] || return 1 + + ((index++)) + done + + return 1 +} + +announce() { + local path="$1" + local name="${path##*/}" + local args=("Received $name" "Saved to ${dir/#$HOME/~}") + + case "${name,,}" in + *.png | *.jpg | *.jpeg | *.gif | *.webp | *.avif | *.bmp | *.tif | *.tiff) + args+=(--image "$path") + ;; + *) + args+=(-g 󰒊) + ;; + esac + + # Clicking the notification opens the file, so this waits for the toast to + # go away. Callers background it to keep receiving in the meantime. + if [[ -n $(omarchy-notification-send "${args[@]}" -a) ]]; then + xdg-open "$path" + fi +} + +deliver() { + local staged target + + while IFS= read -r staged; do + target=$(claim_path "$staged") || continue + announce "$target" & + done < <(find "$staging" -mindepth 1 -maxdepth 1) +} + +# Anything left staged by an interrupted run still deserves delivering. +deliver + +while true; do + if ! tailscale file get --wait --conflict=rename "$staging"; then + $once && exit 1 + sleep 10 + continue + fi + + deliver + $once && exit 0 +done diff --git a/bin/omarchy-tailscale-send b/bin/omarchy-tailscale-send new file mode 100755 index 00000000..84093cc8 --- /dev/null +++ b/bin/omarchy-tailscale-send @@ -0,0 +1,50 @@ +#!/bin/bash + +# omarchy:summary=Send files to a machine on your tailnet with Taildrop +# omarchy:args=<machine> [file...] +# omarchy:examples=omarchy tailscale send dhh-fd | omarchy tailscale send dhh-fd ~/Downloads/notes.pdf + +set -euo pipefail + +if (($# < 1)); then + echo "Usage: omarchy-tailscale-send <machine> [file...]" >&2 + exit 1 +fi + +machine="$1" +shift + +# Address the machine by whatever name we were handed, but talk about it by +# its short name, so a MagicDNS name does not spill into every message. +name="${machine%%.*}" + +files=("$@") + +if ((${#files[@]} == 0)); then + # Command substitution so the chooser's exit status survives: reading it + # through a process substitution reports success for a chooser that never + # opened, which is indistinguishable here from someone deciding not to send. + picked=$(omarchy-file-select --title "Send to $name" --multiple) || status=$? + + if ((${status:-0} > 1)); then + omarchy-notification-send -g "󰒊" -u critical "Could not send to $name" \ + "The file chooser did not open" + exit 1 + fi + + readarray -t files <<<"$picked" + [[ -n $picked ]] || exit 0 +fi + +if ((${#files[@]} == 1)); then + what=$(basename "${files[0]}") +else + what="${#files[@]} files" +fi + +if error=$(tailscale file cp --update-interval=0 -- "${files[@]}" "$machine:" 2>&1); then + omarchy-notification-send -g "󰒊" "Sent to $name" "$what" +else + omarchy-notification-send -g "󰒊" -u critical "Could not send to $name" "${error:-Taildrop transfer failed}" + exit 1 +fi diff --git a/default/fontconfig/conf.avail/50-omarchy.conf b/default/fontconfig/conf.avail/50-omarchy.conf index 95f4f508..eecf66e0 100644 --- a/default/fontconfig/conf.avail/50-omarchy.conf +++ b/default/fontconfig/conf.avail/50-omarchy.conf @@ -38,6 +38,34 @@ </edit> </match> + <!-- Urdu is conventionally set in Nastaliq, so keep it there. Without this + the untargeted Naskh rule below would capture Urdu too. Appended rather + than prepended so it only wins the last-resort race against that rule + and never displaces the family the app actually asked for. --> + <match target="pattern"> + <test name="lang" compare="contains"> + <string>ur</string> + </test> + <edit name="family" mode="append" binding="strong"> + <string>Noto Nastaliq Urdu</string> + </edit> + </match> + + <!-- Chromium and Electron resolve missing glyphs one character at a time + without lang on the pattern, so the rules above never fire for them and + fontconfig falls through to a raw charset scan that lands on Nastaliq + (or on Kufi, once the latin alias chains have widened the pattern). + Naming Naskh as a last-resort family covers that path. Appending keeps + it behind every family the app actually asked for, and charset matching + outranks family matching, so a font that does not cover the codepoint + can never be pulled in: Latin, monospace, emoji, Nerd Font glyphs and + CJK all resolve exactly as before. --> + <match target="pattern"> + <edit name="family" mode="append" binding="strong"> + <string>Noto Naskh Arabic</string> + </edit> + </match> + <alias> <family>system-ui</family> <prefer> diff --git a/default/hypr/apps/davinci-resolve.lua b/default/hypr/apps/davinci-resolve.lua index ba81e12c..aba9414d 100644 --- a/default/hypr/apps/davinci-resolve.lua +++ b/default/hypr/apps/davinci-resolve.lua @@ -1,2 +1,8 @@ --- DaVinci Resolve dialog focus handling. -o.window(".*[Rr]esolve.*", { float = true, stay_focused = true }) +-- DaVinci Resolve window focus handling. Kept fully opaque: the default +-- translucency distorts colour-critical grading work. +o.window(".*[Rr]esolve.*", { + float = true, + stay_focused = true, + tag = "-default-opacity", + opacity = "1 1", +}) diff --git a/default/hypr/apps/system.lua b/default/hypr/apps/system.lua index a7206550..a8c9b250 100644 --- a/default/hypr/apps/system.lua +++ b/default/hypr/apps/system.lua @@ -9,8 +9,12 @@ o.window( tag = "+floating-window", } ) +-- The portal only ever shows dialogs — file pickers, screen shares, permission +-- prompts — so every one of its windows belongs in the floating treatment, +-- whatever the app that asked for it titled it. +o.window("xdg-desktop-portal-gtk", { tag = "+floating-window" }) o.window({ - class = "(xdg-desktop-portal-gtk|sublime_text|DesktopEditors|org.gnome.Nautilus)", + class = "(sublime_text|DesktopEditors|org.gnome.Nautilus)", title = "^(Open.*Files?|Open [F|f]older.*|Save.*Files?|Save.*As|Save|All Files|.*wants to [open|save].*|[C|c]hoose.*)", }, { tag = "+floating-window" }) o.window("dev.tensaku.Tensaku", { float = true }) diff --git a/default/hypr/input.lua b/default/hypr/input.lua index 27f3184d..c3c7d43f 100644 --- a/default/hypr/input.lua +++ b/default/hypr/input.lua @@ -21,14 +21,34 @@ local function read_vconsole() return values end +-- Layouts that can't type Latin letters. Keep in sync with the list in +-- etc/mkinitcpio.conf.d/omarchy_hooks.conf. +local non_latin_layouts = + " af am ara bd bg by et ge gr il in iq ir kg kh kz la lk mk mm mn mv np rs ru sy th tj ua " + local vconsole = read_vconsole() +local kb_layout = vconsole.XKBLAYOUT or "us" +local kb_variant = vconsole.XKBVARIANT or "" +local kb_options = "compose:caps,shift:both_capslock" + +-- Hyprland resolves keybindings against the first entry in kb_layout, not the +-- layout that's currently active, so Omarchy's Latin-keysym bindings (SUPER + W +-- and friends) only fire when a Latin layout leads. Installing with a non-Latin +-- one would otherwise leave the desktop unusable. +if non_latin_layouts:find(" " .. kb_layout:match("^[^,]*") .. " ", 1, true) then + kb_layout = "us," .. kb_layout + kb_variant = "," .. kb_variant + -- Reach the original layout with Left Alt + Right Alt. + kb_options = kb_options .. ",grp:alts_toggle" +end + hl.config({ input = { - kb_layout = vconsole.XKBLAYOUT or "us", - kb_variant = vconsole.XKBVARIANT or "", + kb_layout = kb_layout, + kb_variant = kb_variant, kb_model = "", - kb_options = "compose:caps,shift:both_capslock", + kb_options = kb_options, kb_rules = "", follow_mouse = 1, sensitivity = 0, diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 2e94e8dc..20134af2 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -129,7 +129,7 @@ "setup.default.browser.brave": {"icon":"󰖟","label":"Brave","when":"omarchy-cmd-present brave","checked":"[[ \"$(omarchy-default-browser)\" == \"brave\" ]]","action":"omarchy-default-browser brave"}, "setup.default.browser.brave-origin": {"icon":"󰖟","label":"Brave Origin","when":"omarchy-cmd-present brave-origin","checked":"[[ \"$(omarchy-default-browser)\" == \"brave-origin\" ]]","action":"omarchy-default-browser brave-origin"}, "setup.default.browser.edge": {"icon":"󰇩","label":"Edge","when":"omarchy-cmd-present microsoft-edge-stable","checked":"[[ \"$(omarchy-default-browser)\" == \"edge\" ]]","action":"omarchy-default-browser edge"}, - "setup.default.browser.firefox": {"icon":"󰈹","label":"Firefox","when":"omarchy-cmd-present firefox","checked":"[[ \"$(omarchy-default-browser)\" == \"firefox\" ]]","action":"omarchy-default-browser firefox"}, + "setup.default.browser.firefox": {"icon":"","label":"Firefox","when":"omarchy-cmd-present firefox","checked":"[[ \"$(omarchy-default-browser)\" == \"firefox\" ]]","action":"omarchy-default-browser firefox"}, "setup.default.browser.zen": {"icon":"󰖟","label":"Zen","when":"omarchy-cmd-present zen-browser","checked":"[[ \"$(omarchy-default-browser)\" == \"zen\" ]]","action":"omarchy-default-browser zen"}, "setup.default.terminal": {"icon":"","label":"Terminal"}, "setup.default.terminal.alacritty": {"icon":"","label":"Alacritty","when":"omarchy-cmd-present alacritty","checked":"[[ \"$(omarchy-default-terminal)\" == \"alacritty\" ]]","action":"omarchy-default-terminal alacritty"}, diff --git a/default/systemd/user/omarchy-migrate-notify.service b/default/systemd/user/omarchy-migrate-notify.service new file mode 100644 index 00000000..79207652 --- /dev/null +++ b/default/systemd/user/omarchy-migrate-notify.service @@ -0,0 +1,16 @@ +[Unit] +Description=Notify about pending Omarchy migrations +# Login-only. There used to be an omarchy-update-user-notify.path watching +# /usr/share/omarchy/migrations, but pacman writes that directory during every +# update -- including the blessed `omarchy update`, which runs omarchy-migrate +# itself a step later -- so the watcher notified about migrations that were +# already being applied in the visible update terminal. Checking once per login +# is the only trigger that cannot collide with a running update. +ConditionPathIsDirectory=/usr/share/omarchy/migrations + +[Service] +Type=oneshot +ExecStart=/usr/bin/omarchy-migrate-notify + +[Install] +WantedBy=graphical-session.target diff --git a/default/systemd/user/omarchy-tailscale-receive.service b/default/systemd/user/omarchy-tailscale-receive.service new file mode 100644 index 00000000..040af0b7 --- /dev/null +++ b/default/systemd/user/omarchy-tailscale-receive.service @@ -0,0 +1,12 @@ +[Unit] +Description=Save incoming Taildrop files to the downloads directory +ConditionPathExists=/usr/bin/tailscale + +[Service] +Type=simple +ExecStart=/usr/bin/omarchy-tailscale-receive +Restart=always +RestartSec=5 + +[Install] +WantedBy=graphical-session.target diff --git a/default/systemd/user/omarchy-update-user-notify.path b/default/systemd/user/omarchy-update-user-notify.path deleted file mode 100644 index aaf5233f..00000000 --- a/default/systemd/user/omarchy-update-user-notify.path +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=Watch for Omarchy migrations - -[Path] -# Edge-triggered watch only. PathExistsGlob= is level-triggered: it re-fires -# every time the triggered unit deactivates for as long as the glob matches, -# and applied migrations stay on disk forever, so a glob here busy-loops the -# oneshot service. The once-per-login check lives in the service's own -# WantedBy=graphical-session.target instead. -PathModified=/usr/share/omarchy/migrations -Unit=omarchy-update-user-notify.service - -[Install] -WantedBy=graphical-session.target diff --git a/default/systemd/user/omarchy-update-user-notify.service b/default/systemd/user/omarchy-update-user-notify.service deleted file mode 100644 index 92f25a65..00000000 --- a/default/systemd/user/omarchy-update-user-notify.service +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=Notify about pending Omarchy migrations -ConditionPathIsDirectory=/usr/share/omarchy/migrations - -[Service] -Type=oneshot -ExecStart=/usr/bin/omarchy-migrate-notify - -[Install] -WantedBy=graphical-session.target diff --git a/docs/file-layout.md b/docs/file-layout.md index 2720d382..09065807 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -197,13 +197,19 @@ migration. Migrations run as the user; privileged work should invoke the appropriate helper or privilege prompt. Migrations must be idempotent; machine-wide repairs should no-op when another user already applied them. -Each graphical user has `omarchy-update-user-notify.path` watching the packaged -migration directory for changes, and `omarchy-update-user-notify.service` is -also started once per login via its own `WantedBy=graphical-session.target`. -Either way the service runs `omarchy-migrate-notify` as that user. The notifier checks -`omarchy-migrate --pending`. If this user has missing migration state, it shows a -notification that opens a terminal for `omarchy-migrate`. The notifier never runs -migrations in the background. +Each graphical user has `omarchy-migrate-notify.service`, started once per login +through `WantedBy=graphical-session.target`. The package also ships +`omarchy-update-user-notify.service` as a symlink onto it, so users enabled +under the old unit name keep working before they reach migration `1785095882`. +It runs `omarchy-migrate-notify` as +that user, which checks `omarchy-migrate --pending`. If this user has missing +migration state, it shows a notification that opens a terminal for +`omarchy-migrate`. The notifier never runs migrations in the background. + +Login is the only trigger. Nothing watches the packaged migration directory: a +watcher cannot tell a bypassed `pacman -Syu` from the package transaction inside +a normal `omarchy update`, so it notified about migrations that `omarchy-migrate` +was already applying in the visible update terminal. `omarchy-migrate` waits for any active pacman transaction to finish, then runs pending migrations. It does not need `--force`; migrations happen when state @@ -221,8 +227,8 @@ systemd instance: Voxtype post-update hook. - `install/user/first-run/enable-user-units.sh` — `systemctl --user enable` the shipped user units (`bt-agent`, `omarchy-sleep-lock`, - `omarchy-recover-internal-monitor`, `omarchy-update-user-notify.path`, - `omarchy-update-user-notify.service`). Done here, not at finalize, because + `omarchy-recover-internal-monitor`, `omarchy-migrate-notify.service`). + Done here, not at finalize, because the user manager isn't reachable from the ISO chroot; `ConditionPath*` in the unit files keeps services inert when they don't apply. - `install/user/first-run/gnome-theme.sh`, @@ -250,7 +256,8 @@ the legacy finalization marker from `~/.local/state/omarchy/` into `done/`. finalization. It sources: - `install/config/all.sh` — theme links, lockout limits, lockscreen PAM, - powerprofilesctl shebang fix, docker setup, service enablement, firewall. + powerprofilesctl shebang fix, docker setup, Snapper retention, locate + index tuning, service enablement, firewall. - `install/hardware/all.sh` via `omarchy-setup-hardware` — vendor- and device-specific kernel modules, udev rules, microcode, wireless regdom, ASUS / Framework / Intel / Apple / Lenovo quirks. diff --git a/docs/migrations.md b/docs/migrations.md index 48c2125b..7e6d5538 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -43,16 +43,9 @@ omarchy-hook post-update `omarchy-migrate` waits for any active pacman transaction to finish, then runs all pending migrations for the current user in the visible update terminal. -### During direct pacman updates +### At login -Raw `sudo pacman -Syu` is guarded. Users should normally run: - -```bash -omarchy update -``` - -If a user explicitly bypasses the guard, user sessions watch the packaged -migration directory and run a notifier. The notifier checks: +Every graphical login starts `omarchy-migrate-notify.service`, which checks: ```bash omarchy-migrate --pending @@ -67,6 +60,15 @@ omarchy-migrate The notifier never runs migrations silently in the background. +This is what covers users who did not run the update themselves: someone who +bypassed the pacman guard with `sudo env OMARCHY_ALLOW_DIRECT_PACMAN=1 pacman +-Syu`, and any second user on the machine, whose migration markers are per-user +and therefore still missing after another user updated. + +Login is the only trigger on purpose. Watching the packaged migration directory +also fires during a normal `omarchy update`, which prompts for migrations that +`omarchy-migrate` is about to run in the visible update terminal. + ### Manually Users can safely run: diff --git a/docs/update-process.md b/docs/update-process.md index 200f1ffc..b12f4f51 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -149,20 +149,37 @@ High-level flow: sudo pacman -Syu ├─ pre-transaction guard aborts and tells the user to run omarchy update └─ if explicitly bypassed, upgrades omarchy and related packages - └─ user session notices migration directory changes - ├─ omarchy-update-user-notify.path triggers, if enabled + └─ at that user's next login + ├─ omarchy-migrate-notify.service starts with graphical-session.target ├─ omarchy-migrate-notify checks omarchy-migrate --pending ├─ if this user has missing migration state, show notification └─ click opens terminal: omarchy-migrate ``` +Login is deliberately the only trigger. A watcher on the packaged migration +directory cannot distinguish a bypassed `pacman -Syu` from the package +transaction inside a normal `omarchy update`, so it fired notifications for +migrations that `omarchy-migrate` was about to apply in the visible update +terminal. The retired unit was `omarchy-update-user-notify.path`. + Fallbacks: -- `omarchy-first-run` enables the user notification path unit. -- `omarchy-first-run` also invokes `omarchy-migrate-notify` on graphical - startup, so users who updated before the path unit existed still get prompted - if they have missing migration state. +- `omarchy-first-run` enables `omarchy-migrate-notify.service`, which also + covers users created after install: their per-user migration markers are + missing, so their first login prompts them to run every shipped migration. +- The package ships `omarchy-update-user-notify.service` as a symlink onto + `omarchy-migrate-notify.service`. Users set up before the rename hold an + absolute `graphical-session.target.wants` symlink to the old path, and the + migration that repoints it only runs for users who run an update — the + opposite of who the notifier is for. The alias can be dropped once installs + have run migration `1785095882`. +- The notifier waits for a live notification server before sending, because + `graphical-session.target` can be reached before the shell claims + `org.freedesktop.Notifications`. - The notifier is only a prompt. It does not run migrations in the background. +- A session that is already open when another user updates is not re-checked; + it picks the migrations up at its next login, or whenever that user runs + `omarchy-migrate` or `omarchy update`. - Direct pacman updates do not run `omarchy-hook post-update` unless the user explicitly runs that hook; without a package-update marker, the only pending state we can derive is missing per-user migration markers. @@ -209,7 +226,7 @@ scripts. | `omarchy-update-system-pkgs` | Runs `sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm` with targeted transition `--overwrite` entries so the ALPM guard allows the transaction and early package-layout conflicts are handled. | **Keep for now.** Small leaf command, clear/testable. | | `omarchy-migrate` | Public migration command. Waits for pacman, then runs all pending migrations for the current user. Supports `--pending`. | **Keep.** This replaces the discarded `omarchy-update-user-finalize` name and no longer needs `--force`. | | `omarchy-update-pacman-guard` | ALPM pre-transaction guard that aborts direct `pacman -Syu` style upgrades unless Omarchy set `OMARCHY_UPDATE_PACMAN=1` or the user explicitly set `OMARCHY_ALLOW_DIRECT_PACMAN=1`. | **Keep internal/hidden.** This is what nudges users back to `omarchy update`. | -| `omarchy-migrate-notify` | Internal notification helper for direct pacman updates. Uses `omarchy-migrate --pending` and shows notification only when this user has pending migrations. | **Keep internal/hidden.** Clear name now that the public command is `omarchy-migrate`. | +| `omarchy-migrate-notify` | Internal login-time notification helper. Uses `omarchy-migrate --pending` and shows a notification only when this user has pending migrations. | **Keep internal/hidden.** Clear name now that the public command is `omarchy-migrate`. | | `omarchy-update-user-notify` | Hidden compatibility wrapper for `omarchy-migrate-notify`. | **Temporary.** Keep only for old callers. | | `omarchy-update-available` | Update checker for shell widget and post-update refresh. | **Keep.** Could eventually be renamed `omarchy-update-check`, but current name matches widget semantics. | | `omarchy-update-aur-pkgs` | Updates AUR packages with `yay -Sua` if foreign packages exist and AUR is reachable. | **Question.** Omarchy is package-backed now, but users may still install AUR packages. Keep for now. | @@ -229,7 +246,8 @@ scripts. idempotent when they repair machine-wide state. 2. **Migration notification naming** - - The real helper is `omarchy-migrate-notify`. + - The real helper is `omarchy-migrate-notify`, started by + `omarchy-migrate-notify.service`. - `omarchy-update-user-notify` remains only as a hidden compatibility wrapper. 3. **Update pipeline ownership** diff --git a/install/config/all.sh b/install/config/all.sh index e416b7bc..c4c70752 100644 --- a/install/config/all.sh +++ b/install/config/all.sh @@ -4,5 +4,6 @@ run_logged "$OMARCHY_INSTALL/config/lockscreen-pam.sh" run_logged "$OMARCHY_INSTALL/config/fix-powerprofilesctl-shebang.sh" run_logged "$OMARCHY_INSTALL/config/docker.sh" run_logged "$OMARCHY_INSTALL/config/snapper.sh" +run_logged "$OMARCHY_INSTALL/config/locate.sh" run_logged "$OMARCHY_INSTALL/config/enable-services.sh" run_logged "$OMARCHY_INSTALL/config/firewall.sh" diff --git a/install/config/locate.sh b/install/config/locate.sh new file mode 100644 index 00000000..cc95e9b8 --- /dev/null +++ b/install/config/locate.sh @@ -0,0 +1,32 @@ +UPDATEDB_CONF_PATH="${OMARCHY_UPDATEDB_CONF_PATH:-/etc/updatedb.conf}" + +echo "Configuring locate to skip Btrfs snapshots and index Btrfs subvolumes" + +[[ -f $UPDATEDB_CONF_PATH ]] || exit 0 + +# updatedb refuses to run at all on a config that defines a variable twice, so +# every setting here is rewritten where it already stands and only appended +# when the file has no line for it. + +# Btrfs subvolume mounts (like /home) look like bind mounts, so pruning +# bind mounts leaves them out of the index entirely. +if grep -qE '^[[:space:]]*PRUNE_BIND_MOUNTS[[:space:]]*=' "$UPDATEDB_CONF_PATH"; then + sed -i -E 's|^[[:space:]]*PRUNE_BIND_MOUNTS[[:space:]]*=.*|PRUNE_BIND_MOUNTS = "no"|' "$UPDATEDB_CONF_PATH" +else + printf '%s\n' 'PRUNE_BIND_MOUNTS = "no"' >>"$UPDATEDB_CONF_PATH" +fi + +# Snapper snapshots are nested subvolumes reached by plain directory +# traversal, so without this updatedb indexes the system once per snapshot. +if grep -qE '^[[:space:]]*PRUNEPATHS[[:space:]]*=' "$UPDATEDB_CONF_PATH"; then + # updatedb only accepts quoted values and allows a comment after them. Read + # back what the machine already prunes and write the whole setting out again + # rather than splicing into a line of unknown shape. + pruned=$(sed -nE 's|^[[:space:]]*PRUNEPATHS[[:space:]]*=[[:space:]]*"([^"]*)".*|\1|p' "$UPDATEDB_CONF_PATH" | tail -n 1) + + if [[ " $pruned " != *" /.snapshots "* ]]; then + sed -i -E "s|^[[:space:]]*PRUNEPATHS[[:space:]]*=.*|PRUNEPATHS = \"/.snapshots${pruned:+ $pruned}\"|" "$UPDATEDB_CONF_PATH" + fi +else + printf '%s\n' 'PRUNEPATHS = "/.snapshots"' >>"$UPDATEDB_CONF_PATH" +fi diff --git a/install/user/first-run/enable-user-units.sh b/install/user/first-run/enable-user-units.sh index efc8123c..a5d7cb8e 100755 --- a/install/user/first-run/enable-user-units.sh +++ b/install/user/first-run/enable-user-units.sh @@ -16,5 +16,4 @@ systemctl --user enable --now \ bt-agent.service \ omarchy-recover-internal-monitor.service \ omarchy-sleep-lock.service \ - omarchy-update-user-notify.path \ - omarchy-update-user-notify.service + omarchy-migrate-notify.service diff --git a/migrations/1784809451.sh b/migrations/1784809451.sh new file mode 100644 index 00000000..fb59a794 --- /dev/null +++ b/migrations/1784809451.sh @@ -0,0 +1,30 @@ +echo "Configure locate to skip Btrfs snapshots and index Btrfs subvolumes" + +OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" +locate_config_script="$OMARCHY_PATH/install/config/locate.sh" +UPDATEDB_CONF_PATH="${OMARCHY_UPDATEDB_CONF_PATH:-/etc/updatedb.conf}" + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +[[ -f $UPDATEDB_CONF_PATH ]] || exit 0 +[[ -f $locate_config_script ]] || exit 0 + +if grep -q '^PRUNE_BIND_MOUNTS = "no"' "$UPDATEDB_CONF_PATH" && + grep -E '^PRUNEPATHS' "$UPDATEDB_CONF_PATH" | grep -qE '(^|[[:space:]"])/\.snapshots([[:space:]"]|$)'; then + exit 0 +fi + +as_root env OMARCHY_UPDATEDB_CONF_PATH="$UPDATEDB_CONF_PATH" bash -euo pipefail "$locate_config_script" + +# Rebuild the index with the new exclusions; pruning /.snapshots turns +# multi-hour runs on snapshot-heavy systems back into one-minute runs. Restart +# rather than start: the machines this targets are the ones with an updatedb +# already grinding through every snapshot, and a run that started before the +# rewrite keeps using the config it read at startup. +as_root systemctl restart --no-block plocate-updatedb.service >/dev/null 2>&1 || true diff --git a/migrations/1784809452.sh b/migrations/1784809452.sh new file mode 100644 index 00000000..d70d7659 --- /dev/null +++ b/migrations/1784809452.sh @@ -0,0 +1,56 @@ +echo "Remove Snapper timeline snapshots leaked by earlier defaults" + +SNAPPER_CONFIG_PATH="${OMARCHY_SNAPPER_CONFIG_PATH:-/etc/snapper/configs/root}" + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +command -v snapper >/dev/null || exit 0 +[[ -f $SNAPPER_CONFIG_PATH ]] || exit 0 + +# Only clean up when timeline snapshotting is off, as Omarchy configures it. +# Anyone who deliberately turned it back on keeps their snapshots. Snapper's +# own create-config leaves the file readable by root alone, and a config this +# user cannot read must not pass for one that wants its snapshots kept. +if [[ -r $SNAPPER_CONFIG_PATH ]]; then + grep -qFx 'TIMELINE_CREATE="no"' "$SNAPPER_CONFIG_PATH" || exit 0 +else + as_root grep -qFx 'TIMELINE_CREATE="no"' "$SNAPPER_CONFIG_PATH" || exit 0 +fi + +# Earlier installs ran hourly timeline snapshots. Later configs stopped +# creating them but never deleted the existing ones, and number cleanup +# skips snapshots marked Cleanup=timeline, so they pile up forever: +# hundreds of snapshots pinning 100+ GB of extents on long-running machines. +leaked=$(as_root snapper -c root --csvout list --columns number,cleanup 2>/dev/null | awk -F, '$2 == "timeline" { print $1 }' || true) +[[ -n $leaked ]] || exit 0 + +echo "Deleting $(wc -w <<<"$leaked") leaked timeline snapshots (disk space is reclaimed in the background)" + +# Delete in small batches; one big delete can die on a DBus timeout partway. +# A failed batch must not take the rest of the migration run down with it, so +# the drain is best effort. omarchy-migrate records the migration either way, +# so say what is left rather than counting on a rerun that will not come. +failed=0 +batch=() + +for number in $leaked; do + batch+=("$number") + if (( ${#batch[@]} == 20 )); then + as_root snapper -c root delete "${batch[@]}" || failed=$((failed + ${#batch[@]})) + batch=() + fi +done + +if (( ${#batch[@]} > 0 )); then + as_root snapper -c root delete "${batch[@]}" || failed=$((failed + ${#batch[@]})) +fi + +if (( failed > 0 )); then + echo "$failed snapshots could not be deleted. Finish with: sudo snapper -c root delete <number>" +fi diff --git a/migrations/1784970000.sh b/migrations/1784970000.sh index af5c3001..e01509e5 100644 --- a/migrations/1784970000.sh +++ b/migrations/1784970000.sh @@ -12,10 +12,13 @@ sudo systemctl reload systemd-logind >/dev/null 2>&1 || true # the old five second window in place. omarchy-system-sleep-lock reads this same # property at runtime, so it stays correct either way -- the reboot flag is only # about getting the wider window to take effect. +# +# Both reads have to survive failing: a missing drop-in or an unreachable logind +# is the very condition being tested for, and migrations run under -e. dropin=/etc/systemd/logind.conf.d/20-inhibit-delay.conf -expected_s=$(sed -n 's/^InhibitDelayMaxSec=//p' "$dropin" 2>/dev/null) +expected_s=$(sed -n 's/^InhibitDelayMaxSec=//p' "$dropin" 2>/dev/null || true) effective_us=$(busctl get-property org.freedesktop.login1 /org/freedesktop/login1 \ - org.freedesktop.login1.Manager InhibitDelayMaxUSec 2>/dev/null | awk '{print $2}') + org.freedesktop.login1.Manager InhibitDelayMaxUSec 2>/dev/null | awk '{print $2}' || true) if [[ -z $expected_s || $effective_us != $((expected_s * 1000000)) ]]; then omarchy-state set reboot-required diff --git a/migrations/1785013000.sh b/migrations/1785013000.sh index b511f1a5..b6a07978 100644 --- a/migrations/1785013000.sh +++ b/migrations/1785013000.sh @@ -1,6 +1,7 @@ echo "Move zram tuning to a vendor drop-in" zram_conf="${OMARCHY_ZRAM_CONF:-/etc/systemd/zram-generator.conf}" +zram_dropin="${OMARCHY_ZRAM_DROPIN:-/usr/lib/systemd/zram-generator.conf.d/90-omarchy.conf}" # The tuning ships as /usr/lib/systemd/zram-generator.conf.d/90-omarchy.conf. # Drop-ins outrank the main config file, so a leftover /etc copy decides nothing @@ -8,6 +9,13 @@ zram_conf="${OMARCHY_ZRAM_CONF:-/etc/systemd/zram-generator.conf}" [[ -f $zram_conf ]] || exit 0 +# Only once the replacement is on disk. zram-generator makes no device at all +# when nothing configures one, so until the drop-in lands the /etc copy is the +# only thing standing between this machine and no zram swap. The update pipeline +# installs packages before it runs migrations, but a dev checkout carries +# migrations from a release the installed package does not have yet. +[[ -f $zram_dropin ]] || exit 0 + # Package-owned copies go away with their package on upgrade. pacman -Qo "$zram_conf" &>/dev/null && exit 0 diff --git a/migrations/1785094500.sh b/migrations/1785094500.sh index d858ab63..36c05a89 100644 --- a/migrations/1785094500.sh +++ b/migrations/1785094500.sh @@ -30,7 +30,16 @@ if sudo systemctl daemon-reload; then # pressure keeps its old size until the next boot. zram_used=$(awk '$1 == "/dev/zram0" {print $4}' "$swaps") - if [[ ${zram_used:-0} == 0 ]] && sudo systemctl restart dev-zram0.swap; then + # No row at all means the device is not swap right now, and that covers two + # unlike situations. A device that doesn't exist yet is the one worth acting + # on: the restart brings it up against the config daemon-reload just picked + # up. A device that exists but is swapped off is not, because the restart + # resets it first, and reset returns EBUSY for as long as anything still + # holds it open. That leaves a "Job failed" from systemd in the migration + # output and still ends up asking for the reboot, so go straight there. + if [[ -n $zram_used || ! -e $zram_disksize ]] && + [[ ${zram_used:-0} == 0 ]] && + sudo systemctl restart dev-zram0.swap; then exit 0 fi fi diff --git a/migrations/1785095882.sh b/migrations/1785095882.sh new file mode 100644 index 00000000..cb633bac --- /dev/null +++ b/migrations/1785095882.sh @@ -0,0 +1,39 @@ +echo "Only check for pending migrations at login, not on every package update" + +# omarchy-update-user-notify.path watched /usr/share/omarchy/migrations, but +# pacman writes that directory during every update -- including the blessed +# `omarchy update`, which runs omarchy-migrate a step later. The watcher fired a +# critical notification for migrations that were already being applied in the +# visible update terminal. Retire the watcher and keep only the once-per-login +# check, now named after the command it runs. + +wants_dir="$HOME/.config/systemd/user/graphical-session.target.wants" + +systemctl --user daemon-reload >/dev/null 2>&1 || true + +# The watcher's unit file is already gone, but it stays loaded in a session that +# started before this update, so stop it before it can fire again. +systemctl --user stop omarchy-update-user-notify.path >/dev/null 2>&1 || true + +# Enable the replacement before dropping the old enablement, so a failure here +# can never leave a user with no notifier at all. Enable without --now: this +# usually runs from inside `omarchy update`, and starting the notifier here would +# pop a toast for the migrations running right after it -- the exact behavior +# being removed. `systemctl enable` also needs a live user manager, which +# `omarchy update` over SSH does not have, so fall back to writing precisely the +# symlink it would have written rather than silently doing nothing. +if ! systemctl --user enable omarchy-migrate-notify.service >/dev/null 2>&1; then + mkdir -p "$wants_dir" + ln -sfn /usr/lib/systemd/user/omarchy-migrate-notify.service \ + "$wants_dir/omarchy-migrate-notify.service" +fi + +# Drop the retired enablement by hand instead of through `systemctl disable`. +# The package ships omarchy-update-user-notify.service as a compatibility +# symlink onto the new unit, for users who have not reached this migration yet, +# so disabling that name here would disable the replacement along with it. +rm -f "$wants_dir/omarchy-update-user-notify.path" \ + "$wants_dir/omarchy-update-user-notify.service" + +systemctl --user reset-failed omarchy-update-user-notify.path >/dev/null 2>&1 || true +systemctl --user daemon-reload >/dev/null 2>&1 || true diff --git a/migrations/1785101000.sh b/migrations/1785101000.sh new file mode 100644 index 00000000..2b5aef76 --- /dev/null +++ b/migrations/1785101000.sh @@ -0,0 +1,11 @@ +echo "Save incoming Taildrop files to ~/Downloads" + +if omarchy-cmd-present tailscale; then + systemctl --user daemon-reload >/dev/null 2>&1 || true + + # Report what systemctl actually said; "could not enable" on its own gives + # nothing to act on. + if ! error=$(systemctl --user enable --now omarchy-tailscale-receive.service 2>&1); then + echo "Could not enable omarchy-tailscale-receive.service: $error" + fi +fi diff --git a/shell/plugins/menu/MenuModel.js b/shell/plugins/menu/MenuModel.js index 12ef3c0c..556c1d77 100644 --- a/shell/plugins/menu/MenuModel.js +++ b/shell/plugins/menu/MenuModel.js @@ -309,6 +309,9 @@ function searchScore(items, entry, query) { else if (descriptionTextMatches(needle, descriptionText)) score = 60 if (entry.kind === "menu" || entry.kind === "link") score -= 2 + // App rows sort after all menu items, so they lose the tiebreak below to an + // equal match. Outrank those, but stay inside the tier so better ones win. + if (entry.kind === "app") score -= 5 return score * 1000 + depthFor(items, entry.id) * 25 + entry.order } diff --git a/shell/plugins/panels/tailscale/Model.js b/shell/plugins/panels/tailscale/Model.js index 4153067c..bddf7153 100644 --- a/shell/plugins/panels/tailscale/Model.js +++ b/shell/plugins/panels/tailscale/Model.js @@ -73,10 +73,35 @@ function loginPlan(needsLogin, authUrl) { return { authUrl: "", command: ["tailscale", "up"] } } +// Taildrop is a tailnet feature the admin can turn off, so the button for it +// only makes sense when this profile actually carries the capability. +function hasFileSharing(self) { + var capability = "https://tailscale.com/cap/file-sharing" + var capMap = (self && self.CapMap) || null + if (capMap && capMap[capability] !== undefined) return true + var capabilities = (self && self.Capabilities) || [] + for (var i = 0; i < capabilities.length; i++) { + if (String(capabilities[i]) === capability) return true + } + return false +} + +// Tailscale grades every peer itself — offline, wrong owner, an OS without +// Taildrop, no peer API — so take its word when the status carries one, and +// fall back to same-owner for daemons too old to say. +function isTaildropTarget(peer, selfUserId) { + var target = peer && peer.TaildropTarget + if (typeof target === "number" && target !== 0) return target === 1 + var owner = String((peer && peer.UserID) || "") + return owner !== "" && owner === String(selfUserId || "") +} + function peerFromStatus(id, peer) { return { id: id, HostName: displayHostName(peer.HostName, peer.DNSName), + UserID: String(peer.UserID || ""), + TaildropTarget: typeof peer.TaildropTarget === "number" ? peer.TaildropTarget : 0, DNSName: cleanDnsName(peer.DNSName), DisplayName: displayHostName(peer.HostName, peer.DNSName), TailscaleIPs: filterIPv4(peer.TailscaleIPs || []), @@ -235,6 +260,8 @@ function parseStatus(raw) { selfName: displayHostName(self.HostName, self.DNSName), selfDnsName: cleanDnsName(self.DNSName), selfIp: selfIps.length > 0 ? selfIps[0] : "", + selfUserId: String(self.UserID || ""), + fileSharing: hasFileSharing(self), peers: peers, exitNodes: exitNodes } @@ -285,6 +312,8 @@ if (typeof module !== "undefined") { osIcon: osIcon, accountLabel: accountLabel, loginPlan: loginPlan, + hasFileSharing: hasFileSharing, + isTaildropTarget: isTaildropTarget, isMullvadPeer: isMullvadPeer, peerFromStatus: peerFromStatus, parseExitNodeList: parseExitNodeList, diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml index f188ca76..3202a686 100644 --- a/shell/plugins/panels/tailscale/Panel.qml +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -295,6 +295,13 @@ Panel { scrollCursorIntoView() } + // The file picker takes over from here, so get the panel out of the way. + function sendPeerFile(peer) { + if (!tailscale.canSendFiles(peer)) return + tailscale.sendFile(peer) + close() + } + function openSelectedPeerCopyMenu() { if (!peerColumn || peerIndex < 0 || peerIndex >= peerColumn.children.length) return var item = peerColumn.children[peerIndex] @@ -416,6 +423,7 @@ Panel { else if (t === "c" || t === "C") tailscale.copyPeerIp(root.selectedPeer()) else if (t === "n" || t === "N") tailscale.copyPeerName(root.selectedPeer()) else if (t === "d" || t === "D") tailscale.copyPeerDnsName(root.selectedPeer()) + else if (t === "s" || t === "S") root.sendPeerFile(root.selectedPeer()) } Flickable { @@ -973,6 +981,17 @@ Panel { } } + PanelActionButton { + id: sendButton + visible: tailscale.canSendFiles(peerRow.peer) + iconText: "󰒊" + tooltipText: "Send files" + foreground: root.foreground + fontFamily: root.fontFamily + Layout.alignment: Qt.AlignVCenter + onClicked: root.sendPeerFile(peerRow.peer) + } + PanelActionButton { id: copyButton iconText: "󰆏" diff --git a/shell/plugins/panels/tailscale/README.md b/shell/plugins/panels/tailscale/README.md index cb54f50a..5d542d4f 100644 --- a/shell/plugins/panels/tailscale/README.md +++ b/shell/plugins/panels/tailscale/README.md @@ -10,6 +10,7 @@ Native Omarchy bar widget for Tailscale. - Switch between available Tailscale connections when multiple are available - Browse machines from `tailscale status --json` - Copy a machine's Tailscale IP, host name, or DNS name +- Send files to a machine with Taildrop, when the tailnet allows file sharing ## Keyboard shortcuts @@ -20,6 +21,7 @@ Inside the panel: - `c`: copy selected peer IP - `n`: copy selected peer name - `d`: copy selected peer DNS name +- `s`: send files to selected peer - `t`: toggle Tailscale - `r`: refresh status - `esc`: close @@ -28,6 +30,15 @@ Inside the panel: - `tailscale` CLI on `PATH` - `wl-copy` for clipboard copy actions +- Taildrop enabled for the tailnet, to send files + +## Receiving files + +Incoming Taildrop files are saved to `~/Downloads` by the +`omarchy-tailscale-receive` service, which announces each one with a +notification (an image preview when the file is an image, and a click to open +it). The Tailscale service install enables it; `omarchy tailscale receive` +runs the same loop by hand. ## Icon diff --git a/shell/plugins/panels/tailscale/Service.qml b/shell/plugins/panels/tailscale/Service.qml index ffb5eb17..13fbfbc5 100644 --- a/shell/plugins/panels/tailscale/Service.qml +++ b/shell/plugins/panels/tailscale/Service.qml @@ -24,6 +24,8 @@ Item { property string selfName: "" property string selfDnsName: "" property string selfIp: "" + property string selfUserId: "" + property bool fileSharing: false property string authUrl: "" property var peers: [] property var exitNodes: [] @@ -123,6 +125,26 @@ Item { copyToClipboard(cleanDnsName(peer.DNSName), displayHostName(peer.HostName, peer.DNSName) + " DNS name") } + function peerAddress(peer) { + if (!peer) return "" + if (peer.DNSName) return cleanDnsName(peer.DNSName) + if (peer.HostName) return String(peer.HostName) + var ips = filterIPv4(peer.TailscaleIPs || []) + return ips.length > 0 ? ips[0] : "" + } + + function canSendFiles(peer) { + if (!fileSharing || !running || !peer) return false + return Model.isTaildropTarget(peer, selfUserId) + } + + function sendFile(peer) { + if (!canSendFiles(peer)) return + var target = peerAddress(peer) + if (target === "") return + Quickshell.execDetached(["omarchy-tailscale-send", target]) + } + function refresh(forceAccounts) { if (installed) { refreshStatusAndAccounts(forceAccounts === true) @@ -137,18 +159,21 @@ Item { function refreshStatusAndAccounts(forceAccounts) { if (!installed) return + var launched = false if (!statusProcess.running) { _statusOutput = "" _statusError = "" refreshing = true statusProcess.command = ["tailscale", "status", "--json"] statusProcess.running = true + launched = true } if (!mullvadExitNodesProcess.running) { _mullvadExitNodesOutput = "" _mullvadExitNodesError = "" mullvadExitNodesProcess.command = ["tailscale", "exit-node", "list"] mullvadExitNodesProcess.running = true + launched = true } var now = Date.now() var shouldRefreshAccounts = forceAccounts === true || accounts.length === 0 || now - _lastAccountsRefreshMs > 60000 @@ -158,7 +183,13 @@ Item { _lastAccountsRefreshMs = now accountsProcess.command = ["tailscale", "switch", "--list", "--json"] accountsProcess.running = true + launched = true } + // Arm on the launch that needs watching and leave it alone after that. + // Restarting it every refresh pushes the deadline out ahead of a hung + // process forever once the refresh interval is shorter than the timeout, + // and refreshIntervalSec goes down to five seconds. + if (launched && !pollWatchdog.running) pollWatchdog.start() } function elideStatus(text) { @@ -175,6 +206,8 @@ Item { selfName = "" selfDnsName = "" selfIp = "" + selfUserId = "" + fileSharing = false authUrl = "" peers = [] exitNodes = [] @@ -212,6 +245,8 @@ Item { selfName = parsed.selfName selfDnsName = parsed.selfDnsName selfIp = parsed.selfIp + selfUserId = parsed.selfUserId + fileSharing = parsed.fileSharing peers = parsed.running ? parsed.peers : [] tailnetExitNodes = parsed.running ? parsed.exitNodes : [] exitNodes = parsed.running ? tailnetExitNodes.concat(mullvadRegions) : [] @@ -295,10 +330,7 @@ Item { var mullvadIps = filterIPv4(peer.TailscaleIPs || []) if (mullvadIps.length > 0) return mullvadIps[0] } - if (peer.DNSName) return cleanDnsName(peer.DNSName) - if (peer.HostName) return String(peer.HostName) - var ips = filterIPv4(peer.TailscaleIPs || []) - return ips.length > 0 ? ips[0] : "" + return peerAddress(peer) } function setExitNode(peer) { @@ -386,6 +418,22 @@ Item { onTriggered: root.refresh() } + Timer { + // Every poll is skipped while its own process is still running, so one that + // never exits — tailscale can hang on a network that is coming and going — + // silently stops the panel refreshing at all, and it stays stopped. Reap + // anything still running well inside the refresh interval so the next tick + // starts clean. + id: pollWatchdog + interval: 15000 + repeat: false + onTriggered: { + if (statusProcess.running) statusProcess.running = false + if (mullvadExitNodesProcess.running) mullvadExitNodesProcess.running = false + if (accountsProcess.running) accountsProcess.running = false + } + } + Timer { id: actionStatusTimer interval: 2200 diff --git a/test/shell.d/config-test.sh b/test/shell.d/config-test.sh index 96d68004..14ab4b30 100755 --- a/test/shell.d/config-test.sh +++ b/test/shell.d/config-test.sh @@ -16,14 +16,14 @@ pass "default shell.json is valid JSON" jq -e '.version == 1 and (.bar.layout.left | type == "array") and (.bar.layout.center | type == "array") and (.bar.layout.right | type == "array")' "$ROOT/config/omarchy/shell.json" >/dev/null pass "default shell.json has versioned bar layout" +# Pinning the whole row made this fail every time an unrelated widget moved, +# so assert the adjacency the name is about and let the rest of the row change. jq -e ' def ids: map(.id // .); - .bar.layout.center | ids == [ - "omarchy.indicators", - "omarchy.clock", - "omarchy.weather", - "omarchy.system-update" - ] + (.bar.layout.center | ids) as $ids | + ($ids | index("omarchy.weather")) as $weather | + ($ids | index("omarchy.system-update")) as $update | + $weather != null and $update == $weather + 1 ' "$ROOT/config/omarchy/shell.json" >/dev/null pass "default center layout keeps update next to weather" @@ -94,12 +94,29 @@ import sys from pathlib import Path root = Path(os.environ["ROOT"]) +home = Path.home() pkgs_candidates = [ root.parent / "omarchy-pkgs/pkgbuilds", root.parent / "omarchy/omarchy-pkgs/pkgbuilds", root.parent.parent / "omarchy-pkgs/pkgbuilds", + root.parent / "omacom/omarchy-pkgs/pkgbuilds", + root.parent.parent / "omacom/omarchy-pkgs/pkgbuilds", + home / "Work/omacom/omarchy-pkgs/pkgbuilds", ] -pkgs_root = next((path for path in pkgs_candidates if path.exists()), pkgs_candidates[0]) +# Checkouts differ per machine, so allow an explicit pointer at the sibling repo. +# Accepts either the omarchy-pkgs checkout or its pkgbuilds/ directory. +override = os.environ.get("OMARCHY_PKGS_PATH") +if override: + pkgs_candidates = [Path(override) / "pkgbuilds", Path(override)] + pkgs_candidates +pkgs_root = next((path for path in pkgs_candidates if path.exists()), None) +if pkgs_root is None: + print("not ok - omarchy-pkgs checkout found for PKGBUILD coverage", file=sys.stderr) + print( + "looked in:\n " + "\n ".join(str(path) for path in pkgs_candidates) + + "\nset OMARCHY_PKGS_PATH to the omarchy-pkgs checkout", + file=sys.stderr, + ) + sys.exit(1) settings_pkgbuild_path = pkgs_root / "omarchy-settings/PKGBUILD" omarchy_pkgbuild_path = pkgs_root / "omarchy/PKGBUILD" if not settings_pkgbuild_path.exists(): @@ -120,8 +137,8 @@ package_defaults = [ ("default/systemd/user/bt-agent.service", "/usr/lib/systemd/user/bt-agent.service", "systemd/user/bt-agent.service"), ("default/systemd/user/omarchy-sleep-lock.service", "/usr/lib/systemd/user/omarchy-sleep-lock.service", "systemd/user/omarchy-sleep-lock.service"), ("default/systemd/user/omarchy-recover-internal-monitor.service", "/usr/lib/systemd/user/omarchy-recover-internal-monitor.service", "systemd/user/omarchy-recover-internal-monitor.service"), - ("default/systemd/user/omarchy-update-user-notify.service", "/usr/lib/systemd/user/omarchy-update-user-notify.service", "systemd/user/omarchy-update-user-notify.service"), - ("default/systemd/user/omarchy-update-user-notify.path", "/usr/lib/systemd/user/omarchy-update-user-notify.path", "systemd/user/omarchy-update-user-notify.path"), + ("default/systemd/user/omarchy-migrate-notify.service", "/usr/lib/systemd/user/omarchy-migrate-notify.service", "systemd/user/omarchy-migrate-notify.service"), + ("default/systemd/user/omarchy-tailscale-receive.service", "/usr/lib/systemd/user/omarchy-tailscale-receive.service", "systemd/user/omarchy-tailscale-receive.service"), ("default/systemd/zram-generator.conf.d/90-omarchy.conf", "/usr/lib/systemd/zram-generator.conf.d/90-omarchy.conf", "systemd/zram-generator.conf.d/90-omarchy.conf"), ("default/fonts/omarchy/omarchy.ttf", "/usr/share/fonts/omarchy/omarchy.ttf", "omarchy.ttf"), ("default/snapper/root", "/etc/snapper/config-templates/omarchy", "snapper/root"), @@ -135,6 +152,16 @@ for source, destination, legacy in package_defaults: if destination and (source not in pkgbuild or destination not in pkgbuild): errors.append(f"PKGBUILD does not explicitly install {source} -> {destination}") +# Existing users have an absolute wants symlink to the old unit path, and the +# migration that repoints it only runs for users who run an update -- the +# opposite of who the notifier is for. Dropping this alias strands them. +notify_alias = 'ln -sfn omarchy-migrate-notify.service "$pkgdir/usr/lib/systemd/user/omarchy-update-user-notify.service"' +if notify_alias not in pkgbuild: + errors.append( + "PKGBUILD does not ship the omarchy-update-user-notify.service compatibility " + "alias, so users who have not run migration 1785095882 lose the login notifier" + ) + alpm_hooks = [ "00-omarchy-update-guard.hook", "10-omarchy-hyprland-reload-pause.hook", diff --git a/test/shell.d/hyprland-keyboard-layout-test.sh b/test/shell.d/hyprland-keyboard-layout-test.sh new file mode 100644 index 00000000..68626cc8 --- /dev/null +++ b/test/shell.d/hyprland-keyboard-layout-test.sh @@ -0,0 +1,85 @@ +#!/bin/bash + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +require_command lua + +resolved_input() { + OMARCHY_PATH="$ROOT" OMARCHY_VCONSOLE="${1-}" lua <<'LUA' +package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path + +local vconsole = os.getenv("OMARCHY_VCONSOLE") +local real_open = io.open + +io.open = function(path, mode) + if path ~= "/etc/vconsole.conf" then + return real_open(path, mode) + end + + if not vconsole then + return nil + end + + local file = io.tmpfile() + file:write(vconsole) + file:seek("set") + return file +end + +hl = { + config = function(config) + local input = config.input + print(("[%s] [%s] [%s]"):format(input.kb_layout, input.kb_variant, input.kb_options)) + end, +} + +o = { window = function() end } + +require("default.hypr.input") +LUA +} + +assert_input() { + local description="$1" + local expected="$2" + local actual + + if (( $# > 2 )); then + actual=$(resolved_input "$3") + else + actual=$(resolved_input) + fi + + [[ $actual == "$expected" ]] || + fail "$description" "expected: $expected"$'\n'"actual: $actual" + pass "$description" +} + +base_options="compose:caps,shift:both_capslock" +toggle_options="$base_options,grp:alts_toggle" + +assert_input "missing vconsole.conf falls back to us" "[us] [] [$base_options]" +assert_input "us layout passes through" "[us] [intl] [$base_options]" 'XKBLAYOUT=us +XKBVARIANT=intl +' +assert_input "latin layouts are left alone" "[de] [nodeadkeys] [$base_options]" 'XKBLAYOUT=de +XKBVARIANT=nodeadkeys +' +assert_input "non-latin layout gains us in front" "[us,ara] [,] [$toggle_options]" 'XKBLAYOUT=ara +' +assert_input "prepended us keeps variants aligned" "[us,ru] [,phonetic] [$toggle_options]" 'XKBLAYOUT=ru +XKBVARIANT=phonetic +' +assert_input "non-latin layout in front gains us even when us trails" "[us,il,us] [,] [$toggle_options]" 'XKBLAYOUT=il,us +' + +hooks_conf="$ROOT/etc/mkinitcpio.conf.d/omarchy_hooks.conf" +input_lua="$ROOT/default/hypr/input.lua" + +hooks_layouts=$(awk -F')' '/\) ;;$/ { gsub(/[[:space:]|]+/, "\n", $1); print $1 }' "$hooks_conf" | grep '^[a-z]\+$' | sort) +lua_layouts=$(sed -n '/^local non_latin_layouts =/,+1p' "$input_lua" | grep -o '"[^"]*"' | tr -d '"' | tr ' ' '\n' | grep '^[a-z]\+$' | sort) + +[[ -n $hooks_layouts ]] || fail "non-latin layout list is readable from omarchy_hooks.conf" +[[ $hooks_layouts == "$lua_layouts" ]] || + fail "non-latin layout lists stay in sync" "$(diff <(echo "$hooks_layouts") <(echo "$lua_layouts"))" +pass "non-latin layout lists stay in sync with the initramfs hook" diff --git a/test/shell.d/locate-test.sh b/test/shell.d/locate-test.sh new file mode 100644 index 00000000..cab54c24 --- /dev/null +++ b/test/shell.d/locate-test.sh @@ -0,0 +1,169 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +config_script="$ROOT/install/config/locate.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stock_conf() { + cat >"$1" <<'CONF' +PRUNE_BIND_MOUNTS = "yes" +PRUNEFS = "9p afs autofs cifs fuse nfs nfs4 proc sysfs tmpfs" +PRUNENAMES = ".git .hg .svn" +PRUNEPATHS = "/afs /media /mnt /net /sfs /tmp /udev /var/cache /var/lib/pacman/local /var/lock /var/run /var/spool /var/tmp" +CONF +} + +# updatedb dies on a config that defines a variable twice, so hand every +# rewritten file to the real parser rather than trusting the greps below. +empty_tree="$test_tmp/empty-tree" +mkdir -p "$empty_tree" + +assert_conf_parses() { + command -v updatedb >/dev/null || return 0 + + local errors + errors=$(updatedb --config-file "$1" -U "$empty_tree" -o "$test_tmp/plocate.db" 2>&1 >/dev/null | grep -F "$1:" || true) + [[ -z $errors ]] || fail "updatedb accepts the rewritten config" "$errors" +} + +conf="$test_tmp/updatedb.conf" +stock_conf "$conf" + +OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null + +grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config indexes Btrfs subvolume mounts like /home" +grep -qF 'PRUNEPATHS = "/.snapshots /afs' "$conf" || fail "locate config prunes /.snapshots" +assert_conf_parses "$conf" +pass "locate config skips Btrfs snapshots and indexes Btrfs subvolumes" + +OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null + +[[ $(grep -o '/\.snapshots' "$conf" | wc -l) -eq 1 ]] || fail "locate config is idempotent" +assert_conf_parses "$conf" +pass "locate config leaves an already-configured file alone" + +OMARCHY_UPDATEDB_CONF_PATH="$test_tmp/missing.conf" bash -euo pipefail "$config_script" >/dev/null +pass "locate config tolerates a missing updatedb.conf" + +# A hand-edited updatedb.conf may drop the settings entirely, or write them +# without the spaces around the "=" or the quotes that the stock Arch file uses. +conf="$test_tmp/sparse-updatedb.conf" +printf '%s\n' 'PRUNENAMES = ".git .hg .svn"' >"$conf" + +OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null + +grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config adds a missing PRUNE_BIND_MOUNTS" +grep -qFx 'PRUNEPATHS = "/.snapshots"' "$conf" || fail "locate config adds a missing PRUNEPATHS" +assert_conf_parses "$conf" +pass "locate config adds settings a hand-edited updatedb.conf is missing" + +conf="$test_tmp/unspaced-updatedb.conf" +printf '%s\n' 'PRUNE_BIND_MOUNTS="yes"' 'PRUNEPATHS="/tmp /var/tmp"' >"$conf" + +OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null + +grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config rewrites an unspaced PRUNE_BIND_MOUNTS" +grep -qFx 'PRUNEPATHS = "/.snapshots /tmp /var/tmp"' "$conf" || fail "locate config prunes /.snapshots in an unspaced PRUNEPATHS" +[[ $(grep -c 'PRUNEPATHS' "$conf") -eq 1 ]] || fail "locate config keeps a single PRUNEPATHS setting" +assert_conf_parses "$conf" +pass "locate config handles updatedb.conf written without spaces around =" + +# updatedb allows a comment after a value and indented settings, and defining +# either setting twice makes it refuse to run at all. +conf="$test_tmp/commented-updatedb.conf" +printf '%s\n' ' PRUNE_BIND_MOUNTS = "yes" # subvolumes look like bind mounts' \ + 'PRUNEPATHS = "/tmp" # scratch' >"$conf" + +OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null + +grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate config rewrites an indented PRUNE_BIND_MOUNTS" +grep -qFx 'PRUNEPATHS = "/.snapshots /tmp"' "$conf" || fail "locate config keeps the paths a commented PRUNEPATHS already prunes" +[[ $(grep -c 'PRUNEPATHS' "$conf") -eq 1 ]] || fail "locate config replaces a commented PRUNEPATHS instead of adding a second one" +assert_conf_parses "$conf" +pass "locate config handles indented settings and trailing comments" + +# A hand-edited file may have dropped the quotes updatedb requires, which +# leaves it unparseable until something writes the setting out properly. +conf="$test_tmp/unquoted-updatedb.conf" +printf '%s\n' 'PRUNEPATHS = /tmp' >"$conf" + +OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null + +grep -qFx 'PRUNEPATHS = "/.snapshots"' "$conf" || fail "locate config repairs an unquoted PRUNEPATHS" +[[ $(grep -c 'PRUNEPATHS' "$conf") -eq 1 ]] || fail "locate config replaces an unquoted PRUNEPATHS instead of adding a second one" +assert_conf_parses "$conf" +pass "locate config handles updatedb.conf written without quotes" + +# A path that merely ends in /.snapshots is not the root snapshot directory. +conf="$test_tmp/nested-snapshots-updatedb.conf" +printf '%s\n' 'PRUNEPATHS = "/var/lib/machines/.snapshots"' >"$conf" + +OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/null + +grep -qFx 'PRUNEPATHS = "/.snapshots /var/lib/machines/.snapshots"' "$conf" || fail "locate config prunes /.snapshots alongside a path that ends in it" +assert_conf_parses "$conf" +pass "locate config tells /.snapshots apart from a path that ends in it" + +locate_migration=$(grep -rl 'Configure locate to skip Btrfs snapshots' "$ROOT/migrations" | head -n 1 || true) +[[ -n $locate_migration ]] || fail "locate migration exists" + +fake_bin="$test_tmp/bin" +mkdir -p "$fake_bin" + +cat >"$fake_bin/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB +chmod +x "$fake_bin/sudo" + +cat >"$fake_bin/systemctl" <<'STUB' +#!/bin/bash +printf 'systemctl %s\n' "$*" >>"$TEST_LOG" +STUB +chmod +x "$fake_bin/systemctl" + +conf="$test_tmp/migration-updatedb.conf" +stock_conf "$conf" + +TEST_LOG="$test_tmp/calls.log" \ +PATH="$fake_bin:$PATH" \ +OMARCHY_PATH="$ROOT" \ +OMARCHY_UPDATEDB_CONF_PATH="$conf" \ + bash -euo pipefail "$locate_migration" >/dev/null + +grep -qFx 'PRUNE_BIND_MOUNTS = "no"' "$conf" || fail "locate migration rewrites updatedb.conf" +grep -qF 'PRUNEPATHS = "/.snapshots /afs' "$conf" || fail "locate migration prunes /.snapshots" +grep -qFx 'systemctl restart --no-block plocate-updatedb.service' "$test_tmp/calls.log" || fail "locate migration replaces an in-flight run and rebuilds the index without blocking" +pass "locate migration fixes existing installs and rebuilds the index" + +: >"$test_tmp/calls.log" + +TEST_LOG="$test_tmp/calls.log" \ +PATH="$fake_bin:$PATH" \ +OMARCHY_PATH="$ROOT" \ +OMARCHY_UPDATEDB_CONF_PATH="$conf" \ + bash -euo pipefail "$locate_migration" >/dev/null + +[[ ! -s $test_tmp/calls.log ]] || fail "locate migration skips already-configured installs" +pass "locate migration is a no-op once updatedb.conf is configured" + +# A dev checkout carries migrations from a release whose install scripts the +# checked-out tree may not have yet, and omarchy-migrate runs under set -e. +: >"$test_tmp/calls.log" +conf="$test_tmp/no-config-script-updatedb.conf" +stock_conf "$conf" + +TEST_LOG="$test_tmp/calls.log" \ +PATH="$fake_bin:$PATH" \ +OMARCHY_PATH="$test_tmp/empty" \ +OMARCHY_UPDATEDB_CONF_PATH="$conf" \ + bash -euo pipefail "$locate_migration" >/dev/null || + fail "locate migration survives a tree without the locate config script" + +[[ ! -s $test_tmp/calls.log ]] || fail "locate migration touches nothing without the locate config script" +pass "locate migration is a no-op when the locate config script is missing" diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index 52cedb92..1035f9b6 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -109,6 +109,25 @@ assertDeepEqual( const defaultItems = menu.parseMenuJsonc(defaultMenuJsonc) const defaultById = Object.fromEntries(defaultItems.map(item => [item.id, item])) + +// Needs the real menu: app rows sort after all menu items, and only at that +// item count does the order tiebreak alone bury an installed app. +const rankBase = menu.mergeMenuSources(defaultItems, []) +const ranked = menu.mergeAppRows(rankBase.items, rankBase.itemOrder, [ + { id: 'apps.brave', parent: 'apps', kind: 'app', label: 'Brave', description: '', aliases: [] }, + { id: 'apps.fontforge', parent: 'apps', kind: 'app', label: 'FontForge', description: '', aliases: [] } +]) +const rankScore = (id, query) => menu.searchScore(ranked.items, ranked.items[id], query) +assert( + ['install.browser.brave', 'remove.browser.brave', 'setup.default.browser.brave'].every( + id => rankScore('apps.brave', 'brave') < rankScore(id, 'brave') + ), + 'menu ranks an installed app above menu entries matching the query equally well' +) +assert( + rankScore('style.font', 'font') < rankScore('apps.fontforge', 'font'), + 'menu keeps a better-matching menu entry above a weaker app match' +) const triggerItems = defaultItems.filter(item => item.parent === 'trigger') assertEqual( triggerItems[0].id, diff --git a/test/shell.d/migrate-notify-test.sh b/test/shell.d/migrate-notify-test.sh index 9b182acb..a07888e9 100644 --- a/test/shell.d/migrate-notify-test.sh +++ b/test/shell.d/migrate-notify-test.sh @@ -33,6 +33,12 @@ bash -c "$command" SH chmod +x "$stub_bin/systemd-run" +cat >"$stub_bin/omarchy-notification-wait" <<'SH' +#!/bin/bash +exit 0 +SH +chmod +x "$stub_bin/omarchy-notification-wait" + cat >"$stub_bin/omarchy-notification-send" <<'SH' #!/bin/bash printf '%s\n' "$@" >"$OMARCHY_TEST_NOTIFY_ARGS" diff --git a/test/shell.d/screenrecording-test.sh b/test/shell.d/screenrecording-test.sh index 238dc28b..cb8ca7e1 100644 --- a/test/shell.d/screenrecording-test.sh +++ b/test/shell.d/screenrecording-test.sh @@ -45,6 +45,8 @@ SH chmod +x "$stub_bin"/* export PATH="$stub_bin:$ROOT/bin:$PATH" +# The resize helper anchors to a region file here, so keep it out of the real one +export XDG_RUNTIME_DIR="$tmp_dir" export OMARCHY_TEST_MENU_ARGS="$tmp_dir/menu-args" export OMARCHY_TEST_RECORDER_ARGS="$tmp_dir/recorder-args" export OMARCHY_TEST_NOTIFICATION_ARGS="$tmp_dir/notification-args" @@ -153,6 +155,59 @@ if [[ -s $OMARCHY_TEST_HYPRCTL_ARGS ]]; then fi pass "webcam resize ignores other windows" +region_file="$XDG_RUNTIME_DIR/omarchy-screenrecord-region" + +: >"$OMARCHY_TEST_HYPRCTL_ARGS" +echo "800x600+100+100" >"$region_file" +"$ROOT/bin/omarchy-capture-webcam-resize" reset + +printf '%s\n' \ + 'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 133, y = 150 })' \ + 'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 727, y = 510 })' >"$expected_hyprctl_args" + +if ! cmp -s "$OMARCHY_TEST_HYPRCTL_ARGS" "$expected_hyprctl_args"; then + fail "webcam anchors to the recorded region" "$(diff -u "$expected_hyprctl_args" "$OMARCHY_TEST_HYPRCTL_ARGS")" +fi +pass "webcam anchors to the recorded region" + +printf '%s\n' \ + 'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 178, y = 200 })' \ + 'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 2342, y = 460 })' >"$expected_hyprctl_args" + +for region in "not-a-region" ""; do + : >"$OMARCHY_TEST_HYPRCTL_ARGS" + printf '%s' "$region" >"$region_file" + "$ROOT/bin/omarchy-capture-webcam-resize" reset + + if ! cmp -s "$OMARCHY_TEST_HYPRCTL_ARGS" "$expected_hyprctl_args"; then + fail "webcam falls back to the monitor for an unusable region" "$(diff -u "$expected_hyprctl_args" "$OMARCHY_TEST_HYPRCTL_ARGS")" + fi +done +pass "webcam falls back to the monitor for an unusable region" + +# A region too narrow for presets scaled from its height shrinks the whole +# ladder, so the three sizes stay distinct and each one fits inside the margins +: >"$OMARCHY_TEST_HYPRCTL_ARGS" +echo "200x1200+0+0" >"$region_file" +for size in small medium large; do + "$ROOT/bin/omarchy-capture-webcam-resize" "$size" +done + +printf '%s\n' \ + 'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 64, y = 72 })' \ + 'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 96, y = 1088 })' \ + 'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 89, y = 100 })' \ + 'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 71, y = 1060 })' \ + 'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 120, y = 135 })' \ + 'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 40, y = 1025 })' >"$expected_hyprctl_args" + +if ! cmp -s "$OMARCHY_TEST_HYPRCTL_ARGS" "$expected_hyprctl_args"; then + fail "webcam sizes stay distinct and inside a narrow region" "$(diff -u "$expected_hyprctl_args" "$OMARCHY_TEST_HYPRCTL_ARGS")" +fi +pass "webcam sizes stay distinct and inside a narrow region" + +rm -f "$region_file" + grep -F 'o.bind("SUPER + ALT + code:34", "Make webcam overlay smaller", "omarchy-capture-webcam-resize smaller")' \ "$ROOT/default/hypr/bindings/utilities.lua" >/dev/null || fail "webcam smaller hotkey is configured" grep -F 'o.bind("SUPER + ALT + code:35", "Make webcam overlay larger", "omarchy-capture-webcam-resize larger")' \ diff --git a/test/shell.d/snapper-test.sh b/test/shell.d/snapper-test.sh index 2730543a..68bcee04 100644 --- a/test/shell.d/snapper-test.sh +++ b/test/shell.d/snapper-test.sh @@ -72,7 +72,10 @@ grep -Fx 'systemctl enable --now snapper-cleanup.timer limine-snapper-sync.servi pass "snapshot configure normalizes Snapper policy and services" setup_system="$ROOT/bin/omarchy-setup-system" -grep -F 'config/snapper.sh' "$setup_system" >/dev/null +grep -F 'config/all.sh' "$setup_system" >/dev/null || + fail "system setup runs the config phase" +grep -F 'config/snapper.sh' "$ROOT/install/config/all.sh" >/dev/null || + fail "config phase normalizes Snapper" pass "system setup normalizes Snapper during fresh installs" migration=$(grep -rl 'Normalize Snapper snapshot services' "$ROOT/migrations" | head -n 1 || true) @@ -84,12 +87,18 @@ grep -F 'as_root env OMARCHY_PATH="$OMARCHY_PATH" bash -euo pipefail "$snapper_c ! grep -F 'NUMBER_LIMIT="5"' "$migration" >/dev/null || fail "Snapper service migration does not overwrite working custom retention" pass "Snapper service migration only repairs broken services idempotently" +# Checkouts differ per machine, so allow an explicit pointer at the sibling repo. +# Accepts either the omarchy-pkgs checkout or its pkgbuilds/ directory. find_omarchy_pks_root() { local candidate for candidate in \ + ${OMARCHY_PKGS_PATH:+"$OMARCHY_PKGS_PATH/pkgbuilds" "$OMARCHY_PKGS_PATH"} \ "$ROOT/../omarchy-pkgs/pkgbuilds" \ "$ROOT/../omarchy/omarchy-pkgs/pkgbuilds" \ - "$ROOT/../../omarchy-pkgs/pkgbuilds"; do + "$ROOT/../../omarchy-pkgs/pkgbuilds" \ + "$ROOT/../omacom/omarchy-pkgs/pkgbuilds" \ + "$ROOT/../../omacom/omarchy-pkgs/pkgbuilds" \ + "$HOME/Work/omacom/omarchy-pkgs/pkgbuilds"; do if [[ -d $candidate ]]; then cd "$candidate" && pwd return 0 @@ -111,12 +120,17 @@ grep -F 'cp -a install "$pkgdir/usr/share/omarchy/"' "$omarchy_pkgbuild" >/dev/n grep -F 'cp -a migrations "$pkgdir/usr/share/omarchy/"' "$omarchy_pkgbuild" >/dev/null || fail "omarchy package bundles migrations" pass "omarchy-pkgs packages Snapper template, setup, and migration coverage" +# Same per-machine checkout problem as omarchy-pkgs; OMARCHY_ISO_PATH points at it. find_omarchy_iso_root() { local candidate for candidate in \ + ${OMARCHY_ISO_PATH:+"$OMARCHY_ISO_PATH"} \ "$ROOT/../omarchy-iso" \ "$ROOT/../omarchy/omarchy-iso" \ - "$ROOT/../../omarchy-iso"; do + "$ROOT/../../omarchy-iso" \ + "$ROOT/../omacom/omarchy-iso" \ + "$ROOT/../../omacom/omarchy-iso" \ + "$HOME/Work/omacom/omarchy-iso"; do if [[ -d $candidate ]]; then cd "$candidate" && pwd return 0 diff --git a/test/shell.d/snapper-timeline-leak-test.sh b/test/shell.d/snapper-timeline-leak-test.sh new file mode 100644 index 00000000..21b15ac7 --- /dev/null +++ b/test/shell.d/snapper-timeline-leak-test.sh @@ -0,0 +1,126 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +leak_migration=$(grep -rl 'timeline snapshots leaked by earlier defaults' "$ROOT/migrations" | head -n 1 || true) +[[ -n $leak_migration ]] || fail "Snapper timeline leak migration exists" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +fake_bin="$test_tmp/bin" +mkdir -p "$fake_bin" + +cat >"$fake_bin/sudo" <<'STUB' +#!/bin/bash +printf 'sudo %s\n' "$*" >>"$TEST_LOG" +exec "$@" +STUB +chmod +x "$fake_bin/sudo" + +cat >"$fake_bin/snapper" <<'STUB' +#!/bin/bash +printf 'snapper %s\n' "$*" >>"$TEST_LOG" +if [[ "$*" == *"--csvout list"* ]]; then + echo "number,cleanup" + for i in $(seq 1 45); do + echo "$i,timeline" + done + echo "100,number" + echo "101," +fi +STUB +chmod +x "$fake_bin/snapper" + +snapper_config="$test_tmp/root" +printf '%s\n' 'TIMELINE_CREATE="no"' 'NUMBER_CLEANUP="yes"' >"$snapper_config" + +TEST_LOG="$test_tmp/calls.log" \ +PATH="$fake_bin:$PATH" \ +OMARCHY_SNAPPER_CONFIG_PATH="$snapper_config" \ + bash -euo pipefail "$leak_migration" >/dev/null + +deletes=$(grep -c '^snapper -c root delete ' "$test_tmp/calls.log" || true) +[[ $deletes -eq 3 ]] || fail "leak migration deletes snapshots in batches" "expected 3 delete calls, got $deletes" + +first_batch=$(grep -m1 '^snapper -c root delete ' "$test_tmp/calls.log") +[[ $first_batch == "snapper -c root delete $(seq -s ' ' 1 20)" ]] || fail "leak migration caps delete batches at 20 snapshots" "$first_batch" + +last_batch=$(grep '^snapper -c root delete ' "$test_tmp/calls.log" | tail -n 1) +[[ $last_batch == "snapper -c root delete $(seq -s ' ' 41 45)" ]] || fail "leak migration deletes the final partial batch" "$last_batch" + +! grep -E '^snapper -c root delete .*\b(100|101)\b' "$test_tmp/calls.log" || fail "leak migration only deletes timeline snapshots" +pass "leak migration removes leaked timeline snapshots in batches and keeps the rest" + +# omarchy-migrate runs under set -e, so a batch that dies on a DBus timeout +# would otherwise abort the run and skip every migration queued behind it. +: >"$test_tmp/calls.log" +printf '%s\n' 'TIMELINE_CREATE="no"' 'NUMBER_CLEANUP="yes"' >"$snapper_config" + +cat >"$fake_bin/snapper" <<'STUB' +#!/bin/bash +printf 'snapper %s\n' "$*" >>"$TEST_LOG" +if [[ "$*" == *"--csvout list"* ]]; then + echo "number,cleanup" + for i in $(seq 1 45); do + echo "$i,timeline" + done + exit 0 +fi +echo "failure: dbus timeout" >&2 +exit 1 +STUB + +output=$(TEST_LOG="$test_tmp/calls.log" \ + PATH="$fake_bin:$PATH" \ + OMARCHY_SNAPPER_CONFIG_PATH="$snapper_config" \ + bash -euo pipefail "$leak_migration" 2>/dev/null) || + fail "leak migration survives a failed delete batch" + +deletes=$(grep -c '^snapper -c root delete ' "$test_tmp/calls.log" || true) +[[ $deletes -eq 3 ]] || fail "leak migration keeps draining after a failed batch" "expected 3 delete calls, got $deletes" + +# omarchy-migrate writes the completion marker even when the drain gave up, so +# what is left has to be said out loud rather than left for a rerun. +grep -qF '45 snapshots could not be deleted' <<<"$output" || fail "leak migration reports the snapshots it could not delete" "$output" +pass "leak migration tolerates a batch that fails partway" + +: >"$test_tmp/calls.log" +printf '%s\n' 'TIMELINE_CREATE="yes"' >"$snapper_config" + +TEST_LOG="$test_tmp/calls.log" \ +PATH="$fake_bin:$PATH" \ +OMARCHY_SNAPPER_CONFIG_PATH="$snapper_config" \ + bash -euo pipefail "$leak_migration" >/dev/null + +[[ ! -s $test_tmp/calls.log ]] || fail "leak migration leaves deliberate timeline setups alone" +pass "leak migration skips systems where timeline snapshots are intentional" + +: >"$test_tmp/calls.log" + +TEST_LOG="$test_tmp/calls.log" \ +PATH="$fake_bin:$PATH" \ +OMARCHY_SNAPPER_CONFIG_PATH="$test_tmp/missing" \ + bash -euo pipefail "$leak_migration" >/dev/null + +[[ ! -s $test_tmp/calls.log ]] || fail "leak migration skips systems without a Snapper root config" +pass "leak migration is a no-op without Snapper configured" + +# Snapper's create-config writes a root-only config, and a config this user +# cannot read says nothing about whether timeline snapshots are wanted. +: >"$test_tmp/calls.log" +printf '%s\n' 'TIMELINE_CREATE="no"' >"$snapper_config" +chmod 000 "$snapper_config" + +TEST_LOG="$test_tmp/calls.log" \ +PATH="$fake_bin:$PATH" \ +OMARCHY_SNAPPER_CONFIG_PATH="$snapper_config" \ + bash -euo pipefail "$leak_migration" >/dev/null 2>&1 + +chmod 600 "$snapper_config" + +grep -qF "sudo grep -qFx TIMELINE_CREATE=\"no\" $snapper_config" "$test_tmp/calls.log" || + fail "leak migration reads a root-only Snapper config as root" "$(cat "$test_tmp/calls.log")" +pass "leak migration does not mistake an unreadable Snapper config for an intentional one" diff --git a/test/shell.d/systemd-test.sh b/test/shell.d/systemd-test.sh index cb9bd169..b6346495 100755 --- a/test/shell.d/systemd-test.sh +++ b/test/shell.d/systemd-test.sh @@ -28,12 +28,19 @@ grep -F 'ExecStart=/usr/bin/omarchy-system-sleep-monitor' "$upgrade_to_quattro" grep -F 'reset-failed omarchy-sleep-lock.service' "$upgrade_to_quattro" >/dev/null pass "Omarchy 4 upgrade repairs the legacy sleep lock unit path" -notify_path="$ROOT/default/systemd/user/omarchy-update-user-notify.path" -! grep -q 'PathExistsGlob' "$notify_path" -grep -Fx 'PathModified=/usr/share/omarchy/migrations' "$notify_path" >/dev/null -pass "migration watcher is edge-triggered so applied migrations on disk cannot re-trigger it" +[[ -e $ROOT/default/systemd/user/omarchy-update-user-notify.path ]] && + fail "the retired migration watcher is back; pacman writing the migration directory during omarchy update would notify about migrations that update is already applying" +grep -rlE '^(Path[A-Za-z]+|DirectoryNotEmpty)=.*/usr/share/omarchy/migrations' "$ROOT/default/systemd/user" >/dev/null 2>&1 && + fail "a user unit watches the migration directory again; the notifier must stay login-only" +pass "no unit watches the migration directory, so package updates cannot trigger the notifier" -notify_service="$ROOT/default/systemd/user/omarchy-update-user-notify.service" -! grep -q 'StartLimit' "$notify_service" +notify_service="$ROOT/default/systemd/user/omarchy-migrate-notify.service" +grep -Fx 'ExecStart=/usr/bin/omarchy-migrate-notify' "$notify_service" >/dev/null grep -Fx 'WantedBy=graphical-session.target' "$notify_service" >/dev/null -pass "migration notifier keeps its start-rate limit and still runs once per login" +pass "migration notifier only checks once per login" + +grep -F 'omarchy-migrate-notify.service' "$first_run_units" >/dev/null || + fail "first-run does not enable the login migration notifier" +grep -F 'omarchy-update-user-notify' "$first_run_units" >/dev/null && + fail "first-run still enables the retired notifier units" +pass "first-run enables the login-only migration notifier" diff --git a/test/shell.d/tailscale-receive-test.sh b/test/shell.d/tailscale-receive-test.sh new file mode 100644 index 00000000..4b59afed --- /dev/null +++ b/test/shell.d/tailscale-receive-test.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +WORKDIR=$(mktemp -d) +cleanup() { rm -rf "$WORKDIR"; } +trap cleanup EXIT + +downloads="$WORKDIR/downloads" +mkdir -p "$WORKDIR/bin" "$downloads" "$WORKDIR/outbox" +printf 'mine' >"$downloads/unrelated.txt" + +# Stands in for the daemon handing over whatever is waiting in the inbox. A +# decoy is whatever else drops into the downloads directory while Taildrop is +# still blocking on the next delivery. +cat >"$WORKDIR/bin/tailscale" <<SH +#!/bin/bash +target="\${*: -1}" +[[ -n \${DECOY:-} ]] && printf 'iso' >"$downloads/\$DECOY" +mv "$WORKDIR/outbox/"* "\$target/" +SH + +cat >"$WORKDIR/bin/omarchy-notification-send" <<SH +#!/bin/bash +printf '%s\n' "\$*" >>"$WORKDIR/notifications" +# Only the photo notification gets clicked. +[[ \$* == *photo.png* ]] && echo default +exit 0 +SH + +cat >"$WORKDIR/bin/xdg-open" <<SH +#!/bin/bash +printf '%s\n' "\$1" >>"$WORKDIR/opened" +SH + +chmod +x "$WORKDIR/bin/"* + +receive() { + local expected="$1" + shift + + : >"$WORKDIR/notifications" + PATH="$WORKDIR/bin:$PATH" "$@" "$ROOT/bin/omarchy-tailscale-receive" --once "$downloads" + + for _ in {1..50}; do + (($(wc -l <"$WORKDIR/notifications") >= expected)) && break + sleep 0.1 + done +} + +printf 'png' >"$WORKDIR/outbox/photo.png" +printf 'pdf' >"$WORKDIR/outbox/notes with space.pdf" +receive 2 env + +notifications=$(<"$WORKDIR/notifications") + +[[ -f $downloads/photo.png && -f "$downloads/notes with space.pdf" ]] || + fail "taildrop receive saves incoming files" "$(ls "$downloads")" +pass "taildrop receive saves incoming files" + +grep -qF -- "Received photo.png Saved to $downloads --image $downloads/photo.png" <<<"$notifications" || + fail "taildrop receive previews received images" "$notifications" +pass "taildrop receive previews received images" + +grep -q "^Received notes with space.pdf .* -g " <<<"$notifications" || + fail "taildrop receive announces other files with a glyph" "$notifications" +pass "taildrop receive announces other files with a glyph" + +grep -qxF "$downloads/photo.png" "$WORKDIR/opened" || + fail "taildrop receive opens a clicked file" "$(cat "$WORKDIR/opened" 2>/dev/null)" +pass "taildrop receive opens a clicked file" + +grep -q "unrelated.txt" <<<"$notifications" && + fail "taildrop receive leaves the rest of the downloads directory alone" "$notifications" +pass "taildrop receive leaves the rest of the downloads directory alone" + +# A second delivery of the same name, alongside a download that arrives while +# Taildrop is waiting. +printf 'png' >"$WORKDIR/outbox/photo.png" +receive 1 env DECOY=browser-download.iso + +notifications=$(<"$WORKDIR/notifications") + +[[ -f $downloads/photo-1.png ]] || fail "taildrop receive keeps both files on a name clash" "$(ls "$downloads")" +grep -q "^Received photo-1.png " <<<"$notifications" || + fail "taildrop receive keeps both files on a name clash" "$notifications" +pass "taildrop receive keeps both files on a name clash" + +grep -q "browser-download.iso" <<<"$notifications" && + fail "taildrop receive ignores downloads that arrive while it waits" "$notifications" +pass "taildrop receive ignores downloads that arrive while it waits" + +[[ -z $(ls -A "$downloads/.omarchy-taildrop") ]] || + fail "taildrop receive empties its staging directory" "$(ls -A "$downloads/.omarchy-taildrop")" +pass "taildrop receive empties its staging directory" diff --git a/test/shell.d/tailscale-test.sh b/test/shell.d/tailscale-test.sh index dfb21de2..d46925a0 100644 --- a/test/shell.d/tailscale-test.sh +++ b/test/shell.d/tailscale-test.sh @@ -28,7 +28,9 @@ const status = tailscale.parseStatus(JSON.stringify({ Self: { HostName: 'dhh-fd', DNSName: 'dhh-fd.tail32f559.ts.net.', - TailscaleIPs: ['100.74.97.73'] + TailscaleIPs: ['100.74.97.73'], + UserID: 1001, + CapMap: { 'https://tailscale.com/cap/file-sharing': null } }, Peer: { onlineB: { @@ -38,7 +40,9 @@ const status = tailscale.parseStatus(JSON.stringify({ Online: true, OS: 'linux', ExitNodeOption: true, - ExitNode: true + ExitNode: true, + UserID: 1002, + TaildropTarget: 5 }, offline: { HostName: 'offline', @@ -61,7 +65,9 @@ const status = tailscale.parseStatus(JSON.stringify({ DNSName: 'alpha.tail32f559.ts.net.', TailscaleIPs: ['100.1.1.1', 'fd7a:115c:a1e0::1901:334b'], Online: true, - OS: 'macos' + OS: 'macos', + UserID: 1001, + TaildropTarget: 1 }, mullvadExit: { HostName: 'al-tia-wg-003', @@ -83,6 +89,20 @@ assert(status.peers[1].ExitNodeOption && status.peers[1].ExitNode, 'tailscale pr assertDeepEqual(status.exitNodes.map(peer => peer.HostName), ['zed'], 'tailscale lists only online tailnet exit nodes') assert(tailscale.isMullvadPeer({ HostName: 'al-tia-wg-003', DNSName: 'al-tia-wg-003.mullvad.ts.net.' }), 'tailscale detects Mullvad status peers') +assert(status.fileSharing, 'tailscale reads Taildrop capability from the status capability map') +assertEqual(status.selfUserId, '1001', 'tailscale records the owning user of this machine') +assertDeepEqual(status.peers.map(peer => peer.UserID), ['1001', '1002'], 'tailscale records the owning user of each peer') +assert( + tailscale.hasFileSharing({ Capabilities: ['https://tailscale.com/cap/file-sharing'] }), + 'tailscale reads Taildrop capability from the legacy capability list' +) +assert(!tailscale.hasFileSharing({ CapMap: { funnel: null } }), 'tailscale reports no Taildrop without the capability') +assertDeepEqual(status.peers.map(peer => peer.TaildropTarget), [1, 5], 'tailscale records how Tailscale grades each Taildrop target') +assert(tailscale.isTaildropTarget({ TaildropTarget: 1, UserID: '1001' }, '2002'), 'tailscale trusts an available Taildrop target') +assert(!tailscale.isTaildropTarget({ TaildropTarget: 7, UserID: '1001' }, '1001'), 'tailscale skips peers Tailscale rules out') +assert(tailscale.isTaildropTarget({ UserID: '1001' }, '1001'), 'tailscale falls back to same-owner peers without a grade') +assert(!tailscale.isTaildropTarget({ UserID: '1002' }, '1001'), 'tailscale skips other owners without a grade') + const mullvadNodes = tailscale.parseExitNodeList(` IP HOSTNAME COUNTRY CITY STATUS 100.65.216.13 au-adl-wg-301.mullvad.ts.net Australia Any - diff --git a/test/shell.d/zram-migration-test.sh b/test/shell.d/zram-migration-test.sh index f4fca7d9..cf54c30f 100644 --- a/test/shell.d/zram-migration-test.sh +++ b/test/shell.d/zram-migration-test.sh @@ -26,11 +26,17 @@ STUB chmod +x "$stub_bin/pacman" "$stub_bin/sudo" +# The migration removes the /etc copy only once the drop-in that replaces it is +# installed. Point that at a fixture so the result does not depend on whether +# the machine running the tests happens to carry the real one. +dropin="$TMPDIR/90-omarchy.conf" +: >"$dropin" + # omarchy-migrate runs each migration with `bash -euo pipefail` and stops the # whole chain on a non-zero exit, so match that invocation exactly. run_migration() { local conf="$1" - PATH="$stub_bin:$PATH" OMARCHY_ZRAM_CONF="$conf" \ + PATH="$stub_bin:$PATH" OMARCHY_ZRAM_CONF="$conf" OMARCHY_ZRAM_DROPIN="$dropin" \ bash -euo pipefail "$migration" >/dev/null || fail "migration exits clean for $(basename "$conf")" } @@ -74,7 +80,7 @@ pass "migration keeps a locally edited config" # them. conf="$TMPDIR/owned.conf" printf '[zram0]\ncompression-algorithm = zstd\n' >"$conf" -PATH="$stub_bin:$PATH" PACMAN_OWNS=1 OMARCHY_ZRAM_CONF="$conf" \ +PATH="$stub_bin:$PATH" PACMAN_OWNS=1 OMARCHY_ZRAM_CONF="$conf" OMARCHY_ZRAM_DROPIN="$dropin" \ bash -euo pipefail "$migration" >/dev/null || fail "migration exits clean for a package-owned config" [[ -f $conf ]] || fail "migration keeps a package-owned config" @@ -85,3 +91,14 @@ conf="$TMPDIR/absent.conf" run_migration "$conf" run_migration "$conf" pass "migration no-ops when the config is already gone" + +# Without the drop-in installed, the /etc copy is the only thing configuring +# zram at all. Removing it would leave the machine with no zram device, so the +# migration has to leave it alone and stay clean doing it. +conf="$TMPDIR/no-dropin.conf" +printf '[zram0]\ncompression-algorithm = zstd\n' >"$conf" +PATH="$stub_bin:$PATH" OMARCHY_ZRAM_CONF="$conf" OMARCHY_ZRAM_DROPIN="$TMPDIR/absent-dropin.conf" \ + bash -euo pipefail "$migration" >/dev/null || + fail "migration exits clean when the drop-in is missing" +[[ -f $conf ]] || fail "migration keeps the config when the drop-in is missing" +pass "migration keeps the config until the drop-in is installed" diff --git a/test/shell.d/zram-resize-test.sh b/test/shell.d/zram-resize-test.sh index 589c55aa..a71c3800 100644 --- a/test/shell.d/zram-resize-test.sh +++ b/test/shell.d/zram-resize-test.sh @@ -50,7 +50,15 @@ desired_bytes=$((8192 * 1024 * 1024)) run_migration() { local disksize="$1" used="$2" fail_reload="${3:-0}" - printf '%s' "$disksize" >"$TMPDIR/disksize" + # A machine with no zram device has no /sys/block/zram0 at all, so an empty + # size means the file is gone rather than blank; the migration tells those + # two apart now. + if [[ -n $disksize ]]; then + printf '%s' "$disksize" >"$TMPDIR/disksize" + else + rm -f "$TMPDIR/disksize" + fi + printf 'Filename\tType\tSize\tUsed\tPriority\n' >"$TMPDIR/swaps" [[ -n $used ]] && printf '/dev/zram0 partition 8388604 %s 100\n' "$used" >>"$TMPDIR/swaps" @@ -101,6 +109,15 @@ run_migration "" "" did "systemctl restart dev-zram0.swap" || fail "absent device is created" pass "absent device is created" +# A device that exists but is swapped off reads empty too, and there the +# restart resets it, which fails against whatever still holds it open. Nothing +# to gain over the reboot that would have resized it anyway. +run_migration $((4096 * 1024 * 1024)) "" +did "systemctl restart" && fail "swapped-off device is not restarted" +did "omarchy-state set reboot-required" || fail "swapped-off device asks for a reboot" +pass "swapped-off device is not restarted" +pass "swapped-off device asks for a reboot" + # A failed daemon-reload must fall back to asking for a reboot. run_migration $((4096 * 1024 * 1024)) 0 1 did "systemctl restart" && fail "failed reload does not restart"