Merge quattro into clock-calendar

Both sides tightened the same center-layout assertion. Taking quattro's:
it asserts the weather/update adjacency the test name is about instead of
pinning the whole row, which is what kept breaking it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-07-26 22:07:08 -07:00
co-authored by Claude Opus 5
54 changed files with 1510 additions and 148 deletions
+1 -1
View File
@@ -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/<timestamp>.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/<timestamp>.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`.
+2
View File
@@ -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"
+10 -1
View File
@@ -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 <<JQ
$JQ_DEFS $NORMALIZE
@@ -302,7 +311,7 @@ JQ
commit "$prog" \
--arg id "$id" \
--arg defaultSection "$default_section" \
--arg targetSection "$PLACEMENT_SECTION" \
--arg targetSection "$target_section" \
--arg targetIndex "$PLACEMENT_INDEX" \
--arg before "$PLACEMENT_BEFORE" \
--arg after "$PLACEMENT_AFTER" \
+18 -2
View File
@@ -35,6 +35,7 @@ RESOLUTION=""
FULLSCREEN="false"
STOP_RECORDING="false"
RECORDING_FILE="/tmp/omarchy-screenrecord-filename"
REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region"
LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "/tmp/omarchy-screenrecord.log" || echo "/dev/null")
for arg in "$@"; do
@@ -89,11 +90,26 @@ start_webcam_overlay() {
--title="WebcamOverlay" --wayland-app-id="WebcamOverlay-$WEBCAM_SIZE" \
--no-border --no-audio --no-osc --osd-level=0 \
--really-quiet &>/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=""
+35 -8
View File
@@ -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 <smaller|larger|reset|small|medium|large>" >&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 \
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/python3
# omarchy:summary=Pick files with the desktop file chooser
# omarchy:args=[--title <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)
+1 -38
View File
@@ -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
+3
View File
@@ -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
+3 -2
View File
@@ -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
+10 -4
View File
@@ -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
+30
View File
@@ -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
+1
View File
@@ -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
+101
View File
@@ -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
+50
View File
@@ -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
@@ -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>
+8 -2
View File
@@ -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",
})
+5 -1
View File
@@ -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 })
+23 -3
View File
@@ -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,
+1 -1
View File
@@ -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"},
@@ -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
@@ -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
@@ -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
@@ -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
+17 -10
View File
@@ -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.
+11 -9
View File
@@ -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:
+26 -8
View File
@@ -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**
+1
View File
@@ -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"
+32
View File
@@ -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
+1 -2
View File
@@ -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
+30
View File
@@ -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
+56
View File
@@ -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
+5 -2
View File
@@ -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
+8
View File
@@ -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
+10 -1
View File
@@ -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
+39
View File
@@ -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
+11
View File
@@ -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
+3
View File
@@ -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
}
+29
View File
@@ -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,
+19
View File
@@ -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: "󰆏"
+11
View File
@@ -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
+52 -4
View File
@@ -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
+36 -9
View File
@@ -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",
@@ -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"
+169
View File
@@ -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"
+19
View File
@@ -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,
+6
View File
@@ -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"
+55
View File
@@ -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")' \
+17 -3
View File
@@ -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
+126
View File
@@ -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"
+14 -7
View File
@@ -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"
+97
View File
@@ -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"
+23 -3
View File
@@ -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 -
+19 -2
View File
@@ -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"
+18 -1
View File
@@ -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"