From 8ef4eb24a2c0a9c9ef7a476c8c57a911936b9ef8 Mon Sep 17 00:00:00 2001 From: Martin Bastien Date: Thu, 23 Jul 2026 09:10:16 -0400 Subject: [PATCH 01/19] Keep Btrfs snapshots out of the locate index and put /home in it The stock Arch updatedb.conf interacts badly with Omarchy's Btrfs layout in both directions: - Snapper snapshots under /.snapshots are nested subvolumes reached by plain directory traversal, so updatedb indexes the entire system once per snapshot. On machines that accumulated snapshots this means multi-hour updatedb runs at full CPU, gigabytes of RAM, and a multi-gigabyte plocate.db (observed: 18 GB db, 7.5 h runs at 96% CPU with 592 snapshots; 43 MB and ~1 min after the fix). - PRUNE_BIND_MOUNTS="yes" treats Btrfs subvolume mounts like /home as bind mounts, so locate finds nothing in home directories at all. Configure updatedb.conf at install time and migrate existing installs, then rebuild the index in the background. Both settings are matched tolerantly and appended when absent, so a hand-edited updatedb.conf is fixed rather than silently skipped. --- install/config/all.sh | 1 + install/config/locate.sh | 23 +++++++++ migrations/1784809451.sh | 30 +++++++++++ test/shell.d/locate-test.sh | 100 ++++++++++++++++++++++++++++++++++++ 4 files changed, 154 insertions(+) create mode 100644 install/config/locate.sh create mode 100644 migrations/1784809451.sh create mode 100644 test/shell.d/locate-test.sh diff --git a/install/config/all.sh b/install/config/all.sh index e416b7bc..c4c70752 100644 --- a/install/config/all.sh +++ b/install/config/all.sh @@ -4,5 +4,6 @@ run_logged "$OMARCHY_INSTALL/config/lockscreen-pam.sh" run_logged "$OMARCHY_INSTALL/config/fix-powerprofilesctl-shebang.sh" run_logged "$OMARCHY_INSTALL/config/docker.sh" run_logged "$OMARCHY_INSTALL/config/snapper.sh" +run_logged "$OMARCHY_INSTALL/config/locate.sh" run_logged "$OMARCHY_INSTALL/config/enable-services.sh" run_logged "$OMARCHY_INSTALL/config/firewall.sh" diff --git a/install/config/locate.sh b/install/config/locate.sh new file mode 100644 index 00000000..d532f5df --- /dev/null +++ b/install/config/locate.sh @@ -0,0 +1,23 @@ +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 + +# Btrfs subvolume mounts (like /home) look like bind mounts, so pruning +# bind mounts leaves them out of the index entirely. +if grep -qE '^PRUNE_BIND_MOUNTS[[:space:]]*=' "$UPDATEDB_CONF_PATH"; then + sed -i -E 's|^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 -E '^PRUNEPATHS[[:space:]]*=' "$UPDATEDB_CONF_PATH" | grep -qF '/.snapshots'; then + if grep -qE '^PRUNEPATHS[[:space:]]*=[[:space:]]*"' "$UPDATEDB_CONF_PATH"; then + sed -i -E 's|^(PRUNEPATHS[[:space:]]*=[[:space:]]*")|\1/.snapshots |' "$UPDATEDB_CONF_PATH" + else + printf '%s\n' 'PRUNEPATHS = "/.snapshots"' >>"$UPDATEDB_CONF_PATH" + fi +fi diff --git a/migrations/1784809451.sh b/migrations/1784809451.sh new file mode 100644 index 00000000..0cf9d1a0 --- /dev/null +++ b/migrations/1784809451.sh @@ -0,0 +1,30 @@ +echo "Configure locate to skip Btrfs snapshots and index Btrfs subvolumes" + +OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" +locate_config_script=/usr/share/omarchy/install/config/locate.sh +if [[ ! -f $locate_config_script ]]; then + locate_config_script="$OMARCHY_PATH/install/config/locate.sh" +fi + +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 + +if grep -q '^PRUNE_BIND_MOUNTS = "no"' "$UPDATEDB_CONF_PATH" && + grep -E '^PRUNEPATHS' "$UPDATEDB_CONF_PATH" | grep -qF '/.snapshots'; 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. +as_root systemctl start --no-block plocate-updatedb.service >/dev/null 2>&1 || true diff --git a/test/shell.d/locate-test.sh b/test/shell.d/locate-test.sh new file mode 100644 index 00000000..700b5bd2 --- /dev/null +++ b/test/shell.d/locate-test.sh @@ -0,0 +1,100 @@ +#!/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 +} + +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" +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" +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 "=" 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" +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" +pass "locate config handles updatedb.conf written without spaces around =" + +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 start --no-block plocate-updatedb.service' "$test_tmp/calls.log" || fail "locate migration rebuilds the locate 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" From d548c064eef6509e2a4998ac427be7cb75e00074 Mon Sep 17 00:00:00 2001 From: Martin Bastien Date: Thu, 23 Jul 2026 09:10:16 -0400 Subject: [PATCH 02/19] Remove Snapper timeline snapshots leaked by earlier defaults Installs from before the Snapper setup was normalized ran hourly timeline snapshots. Newer configs stopped creating them but never deleted the existing ones, and number cleanup skips snapshots marked Cleanup=timeline, so they sit there forever: one machine installed from the 2026-05-11 ISO had accumulated 592 of them, silently pinning 219 GB of disk. The limine-snapper-sync limit-mismatch warning that would have surfaced this is disabled by default since the notifier migration. Delete leaked timeline snapshots in batches of 20 (a single mass delete can die on a DBus timeout partway through), and only when TIMELINE_CREATE="no" so anyone who deliberately re-enabled timeline snapshotting keeps their setup untouched. The drain is best effort: a batch that fails is skipped rather than aborting the migration run and everything queued behind it, since the next run re-lists whatever is left. --- migrations/1784809452.sh | 43 +++++++++ test/shell.d/snapper-timeline-leak-test.sh | 104 +++++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 migrations/1784809452.sh create mode 100644 test/shell.d/snapper-timeline-leak-test.sh diff --git a/migrations/1784809452.sh b/migrations/1784809452.sh new file mode 100644 index 00000000..e32da36b --- /dev/null +++ b/migrations/1784809452.sh @@ -0,0 +1,43 @@ +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. +grep -qFx 'TIMELINE_CREATE="no"' "$SNAPPER_CONFIG_PATH" || exit 0 + +# 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: whatever survives is picked up by the next run. +batch=() +for number in $leaked; do + batch+=("$number") + if (( ${#batch[@]} == 20 )); then + as_root snapper -c root delete "${batch[@]}" || true + batch=() + fi +done + +if (( ${#batch[@]} > 0 )); then + as_root snapper -c root delete "${batch[@]}" || true +fi diff --git a/test/shell.d/snapper-timeline-leak-test.sh b/test/shell.d/snapper-timeline-leak-test.sh new file mode 100644 index 00000000..a5adb84f --- /dev/null +++ b/test/shell.d/snapper-timeline-leak-test.sh @@ -0,0 +1,104 @@ +#!/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 +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 + +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 || + 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" +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" From ab798d80b2fdad773626ff06e45d5d10d33d6762 Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Sun, 26 Jul 2026 14:25:31 -0400 Subject: [PATCH 03/19] Rank installed apps above equally matching menu entries Searching the menu for an installed app buried it: "brave" listed Setup > Defaults > Browser, Install > Browser and Remove > Browser ahead of the Brave app itself. All four are exact label matches scoring 0, so the tiebreak falls to declaration order, and mergeAppRows appends app rows after every static item. Bias app rows ahead of menu entries that match equally well. The bias is smaller than the gap between match tiers, so a menu entry that matches the query better still sorts first. Co-Authored-By: Claude Opus 5 (1M context) --- shell/plugins/menu/MenuModel.js | 6 ++++++ test/shell.d/menu-test.sh | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/shell/plugins/menu/MenuModel.js b/shell/plugins/menu/MenuModel.js index 12ef3c0c..3de9b8ee 100644 --- a/shell/plugins/menu/MenuModel.js +++ b/shell/plugins/menu/MenuModel.js @@ -309,6 +309,12 @@ function searchScore(items, entry, query) { else if (descriptionTextMatches(needle, descriptionText)) score = 60 if (entry.kind === "menu" || entry.kind === "link") score -= 2 + // mergeAppRows appends app rows after every menu item, so an app always + // loses the order tiebreak below to a menu entry that matches just as well. + // Searching an installed app's name should launch it, not offer to install + // or remove it. The bias stays under the 10-point gap between match tiers, + // so a menu entry that matches better still sorts first. + if (entry.kind === "app") score -= 5 return score * 1000 + depthFor(items, entry.id) * 25 + entry.order } diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index 52cedb92..8f838702 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -109,6 +109,26 @@ assertDeepEqual( const defaultItems = menu.parseMenuJsonc(defaultMenuJsonc) const defaultById = Object.fromEntries(defaultItems.map(item => [item.id, item])) + +// App rows land after every static item, so ranking has to survive the real +// menu's item count: with hundreds of entries ahead of them, the order +// tiebreak alone buries an installed app under Install and Remove. +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, From 0c6b07e2c492fad4a22d05cf1b634dbd9f0d450b Mon Sep 17 00:00:00 2001 From: Scott Jones Date: Sun, 26 Jul 2026 14:34:43 -0400 Subject: [PATCH 04/19] Trim ranking comments Say it once and match mergeAppRows: app rows sort after all menu items, not interleaved among them. Co-Authored-By: Claude Opus 5 (1M context) --- shell/plugins/menu/MenuModel.js | 7 ++----- test/shell.d/menu-test.sh | 5 ++--- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/shell/plugins/menu/MenuModel.js b/shell/plugins/menu/MenuModel.js index 3de9b8ee..556c1d77 100644 --- a/shell/plugins/menu/MenuModel.js +++ b/shell/plugins/menu/MenuModel.js @@ -309,11 +309,8 @@ function searchScore(items, entry, query) { else if (descriptionTextMatches(needle, descriptionText)) score = 60 if (entry.kind === "menu" || entry.kind === "link") score -= 2 - // mergeAppRows appends app rows after every menu item, so an app always - // loses the order tiebreak below to a menu entry that matches just as well. - // Searching an installed app's name should launch it, not offer to install - // or remove it. The bias stays under the 10-point gap between match tiers, - // so a menu entry that matches better still sorts first. + // App rows sort after all menu items, so they lose the tiebreak below to an + // equal match. Outrank those, but stay inside the tier so better ones win. if (entry.kind === "app") score -= 5 return score * 1000 + depthFor(items, entry.id) * 25 + entry.order diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index 8f838702..1035f9b6 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -110,9 +110,8 @@ assertDeepEqual( const defaultItems = menu.parseMenuJsonc(defaultMenuJsonc) const defaultById = Object.fromEntries(defaultItems.map(item => [item.id, item])) -// App rows land after every static item, so ranking has to survive the real -// menu's item count: with hundreds of entries ahead of them, the order -// tiebreak alone buries an installed app under Install and Remove. +// 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: [] }, From 9ec7916c0b7087fe9ebd6a14934498453269b0ee Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 14:07:41 -0700 Subject: [PATCH 05/19] Keep the inhibit-delay check alive when there is nothing to check Migrations run under bash -euo pipefail, where an assignment from a failed command substitution ends the script. Reading InhibitDelayMaxSec out of a drop-in that is not there exits sed 2, so the migration died at exactly the condition it was written to detect: instead of flagging reboot-required, it aborted before reaching the flag. The busctl read had the same shape, with pipefail standing in for the failed substitution. An aborted migration is never marked complete and takes omarchy-migrate's own -e down with it, so the two migrations queued behind this one stopped running as well. Co-Authored-By: Claude Opus 5 (1M context) --- migrations/1784970000.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/migrations/1784970000.sh b/migrations/1784970000.sh index af5c3001..e01509e5 100644 --- a/migrations/1784970000.sh +++ b/migrations/1784970000.sh @@ -12,10 +12,13 @@ sudo systemctl reload systemd-logind >/dev/null 2>&1 || true # the old five second window in place. omarchy-system-sleep-lock reads this same # property at runtime, so it stays correct either way -- the reboot flag is only # about getting the wider window to take effect. +# +# Both reads have to survive failing: a missing drop-in or an unreachable logind +# is the very condition being tested for, and migrations run under -e. dropin=/etc/systemd/logind.conf.d/20-inhibit-delay.conf -expected_s=$(sed -n 's/^InhibitDelayMaxSec=//p' "$dropin" 2>/dev/null) +expected_s=$(sed -n 's/^InhibitDelayMaxSec=//p' "$dropin" 2>/dev/null || true) effective_us=$(busctl get-property org.freedesktop.login1 /org/freedesktop/login1 \ - org.freedesktop.login1.Manager InhibitDelayMaxUSec 2>/dev/null | awk '{print $2}') + org.freedesktop.login1.Manager InhibitDelayMaxUSec 2>/dev/null | awk '{print $2}' || true) if [[ -z $expected_s || $effective_us != $((expected_s * 1000000)) ]]; then omarchy-state set reboot-required From 171b6374c6895e86876a6ec3dda9083dd7559fc2 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 14:27:44 -0700 Subject: [PATCH 06/19] Keep the zram fallback until its replacement is installed zram-generator creates no device at all when nothing configures one, so the /etc copy is the only thing holding up swap until the vendor drop-in lands. Removing it early costs a machine its zram entirely, not just its tuning. The update pipeline installs packages before it runs migrations, so a packaged machine always has the drop-in by then. A dev checkout does not: omarchy-update-dev pulls migrations from a release the installed package has never seen, and no ordering of the pipeline can produce a file that has not been built yet. Checking for the drop-in is what makes the removal safe rather than well sequenced. The tests pinned the drop-in path to a fixture as well. Left at the real path they would pass or fail on whether the machine running them happened to carry the packaged copy. Co-Authored-By: Claude Opus 5 (1M context) --- migrations/1785013000.sh | 8 ++++++++ test/shell.d/zram-migration-test.sh | 21 +++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/migrations/1785013000.sh b/migrations/1785013000.sh index b511f1a5..b6a07978 100644 --- a/migrations/1785013000.sh +++ b/migrations/1785013000.sh @@ -1,6 +1,7 @@ echo "Move zram tuning to a vendor drop-in" zram_conf="${OMARCHY_ZRAM_CONF:-/etc/systemd/zram-generator.conf}" +zram_dropin="${OMARCHY_ZRAM_DROPIN:-/usr/lib/systemd/zram-generator.conf.d/90-omarchy.conf}" # The tuning ships as /usr/lib/systemd/zram-generator.conf.d/90-omarchy.conf. # Drop-ins outrank the main config file, so a leftover /etc copy decides nothing @@ -8,6 +9,13 @@ zram_conf="${OMARCHY_ZRAM_CONF:-/etc/systemd/zram-generator.conf}" [[ -f $zram_conf ]] || exit 0 +# Only once the replacement is on disk. zram-generator makes no device at all +# when nothing configures one, so until the drop-in lands the /etc copy is the +# only thing standing between this machine and no zram swap. The update pipeline +# installs packages before it runs migrations, but a dev checkout carries +# migrations from a release the installed package does not have yet. +[[ -f $zram_dropin ]] || exit 0 + # Package-owned copies go away with their package on upgrade. pacman -Qo "$zram_conf" &>/dev/null && exit 0 diff --git a/test/shell.d/zram-migration-test.sh b/test/shell.d/zram-migration-test.sh index f4fca7d9..cf54c30f 100644 --- a/test/shell.d/zram-migration-test.sh +++ b/test/shell.d/zram-migration-test.sh @@ -26,11 +26,17 @@ STUB chmod +x "$stub_bin/pacman" "$stub_bin/sudo" +# The migration removes the /etc copy only once the drop-in that replaces it is +# installed. Point that at a fixture so the result does not depend on whether +# the machine running the tests happens to carry the real one. +dropin="$TMPDIR/90-omarchy.conf" +: >"$dropin" + # omarchy-migrate runs each migration with `bash -euo pipefail` and stops the # whole chain on a non-zero exit, so match that invocation exactly. run_migration() { local conf="$1" - PATH="$stub_bin:$PATH" OMARCHY_ZRAM_CONF="$conf" \ + PATH="$stub_bin:$PATH" OMARCHY_ZRAM_CONF="$conf" OMARCHY_ZRAM_DROPIN="$dropin" \ bash -euo pipefail "$migration" >/dev/null || fail "migration exits clean for $(basename "$conf")" } @@ -74,7 +80,7 @@ pass "migration keeps a locally edited config" # them. conf="$TMPDIR/owned.conf" printf '[zram0]\ncompression-algorithm = zstd\n' >"$conf" -PATH="$stub_bin:$PATH" PACMAN_OWNS=1 OMARCHY_ZRAM_CONF="$conf" \ +PATH="$stub_bin:$PATH" PACMAN_OWNS=1 OMARCHY_ZRAM_CONF="$conf" OMARCHY_ZRAM_DROPIN="$dropin" \ bash -euo pipefail "$migration" >/dev/null || fail "migration exits clean for a package-owned config" [[ -f $conf ]] || fail "migration keeps a package-owned config" @@ -85,3 +91,14 @@ conf="$TMPDIR/absent.conf" run_migration "$conf" run_migration "$conf" pass "migration no-ops when the config is already gone" + +# Without the drop-in installed, the /etc copy is the only thing configuring +# zram at all. Removing it would leave the machine with no zram device, so the +# migration has to leave it alone and stay clean doing it. +conf="$TMPDIR/no-dropin.conf" +printf '[zram0]\ncompression-algorithm = zstd\n' >"$conf" +PATH="$stub_bin:$PATH" OMARCHY_ZRAM_CONF="$conf" OMARCHY_ZRAM_DROPIN="$TMPDIR/absent-dropin.conf" \ + bash -euo pipefail "$migration" >/dev/null || + fail "migration exits clean when the drop-in is missing" +[[ -f $conf ]] || fail "migration keeps the config when the drop-in is missing" +pass "migration keeps the config until the drop-in is installed" From 425c3ff84d47828ae703961a266939d089683c18 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 14:27:31 -0700 Subject: [PATCH 07/19] Only check for pending migrations at login 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 the migrations the update was already applying in the visible terminal. A watcher cannot tell that apart from a bypassed pacman -Syu, so the only trigger that never collides with a running update is a once-per-login check. The service that already ran at graphical-session.target is now the whole mechanism, renamed after the command it runs. That is also all the second-user case needs: markers are per-user, so anyone who did not run the update finds them missing at their next login. Login timing means the toast can be sent before the shell has claimed org.freedesktop.Notifications, so the notifier waits for a live server first. The wait is omarchy-first-run's, lifted into omarchy-notification-wait rather than duplicated. The package keeps omarchy-update-user-notify.service as a symlink onto the new unit. Existing users hold an absolute wants symlink to the old path, and the migration that repoints it only runs for users who run an update, which is the opposite of who the notifier is for. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 +- bin/omarchy-first-run | 39 +------------------ bin/omarchy-migrate | 5 ++- bin/omarchy-migrate-notify | 14 +++++-- bin/omarchy-notification-wait | 30 ++++++++++++++ .../user/omarchy-migrate-notify.service | 16 ++++++++ .../user/omarchy-update-user-notify.path | 14 ------- .../user/omarchy-update-user-notify.service | 10 ----- docs/file-layout.md | 24 +++++++----- docs/migrations.md | 20 +++++----- docs/update-process.md | 34 ++++++++++++---- install/user/first-run/enable-user-units.sh | 3 +- migrations/1785095882.sh | 39 +++++++++++++++++++ test/shell.d/config-test.sh | 13 ++++++- test/shell.d/migrate-notify-test.sh | 6 +++ test/shell.d/systemd-test.sh | 21 ++++++---- 16 files changed, 184 insertions(+), 106 deletions(-) create mode 100755 bin/omarchy-notification-wait create mode 100644 default/systemd/user/omarchy-migrate-notify.service delete mode 100644 default/systemd/user/omarchy-update-user-notify.path delete mode 100644 default/systemd/user/omarchy-update-user-notify.service create mode 100644 migrations/1785095882.sh diff --git a/AGENTS.md b/AGENTS.md index 23897a1b..8a8da1c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -262,7 +262,7 @@ This copies `$OMARCHY_PATH/config/hypr/hyprlock.conf` to `~/.config/hypr/hyprloc Read `docs/migrations.md` before creating or changing migrations. -Migrations are per-user and run through `omarchy-migrate` during `omarchy update` or from the migration notification. Put migrations directly under `migrations/.sh`. Pending state is per-user under `~/.local/state/omarchy/migrations/`, so every user gets a chance to run every migration. Migrations run as the user; privileged work should invoke the appropriate helper or privilege prompt, and no-op when another user already applied it. +Migrations are per-user and run through `omarchy-migrate` during `omarchy update` or from the login-time migration notification. Put migrations directly under `migrations/.sh`. Pending state is per-user under `~/.local/state/omarchy/migrations/`, so every user gets a chance to run every migration. Migrations run as the user; privileged work should invoke the appropriate helper or privilege prompt, and no-op when another user already applied it. To create a new migration, run `omarchy-dev-add-migration --no-edit`. diff --git a/bin/omarchy-first-run b/bin/omarchy-first-run index c31bd30c..a771a9c1 100755 --- a/bin/omarchy-first-run +++ b/bin/omarchy-first-run @@ -53,37 +53,6 @@ log_first_run() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >>"$FIRST_RUN_LOG" } -notification_server_ready() { - if omarchy-cmd-present gdbus; then - gdbus call --session \ - --dest org.freedesktop.Notifications \ - --object-path /org/freedesktop/Notifications \ - --method org.freedesktop.Notifications.GetServerInformation >/dev/null 2>&1 - elif omarchy-cmd-present busctl; then - busctl --user call \ - org.freedesktop.Notifications \ - /org/freedesktop/Notifications \ - org.freedesktop.Notifications \ - GetServerInformation >/dev/null 2>&1 - else - return 0 - fi -} - -wait_for_notifications() { - omarchy-cmd-present omarchy-shell || return 0 - - for _ in {1..100}; do - if omarchy-shell notifications ping >/dev/null 2>&1 && notification_server_ready; then - return 0 - fi - sleep 0.1 - done - - log_first_run "Timed out waiting for notification service; continuing" - return 0 -} - run_first_run_step() { local name="$1" shift @@ -98,12 +67,6 @@ run_first_run_step() { fi } -wait_for_notifications - -run_first_run_step "enable migration notification watcher" \ - systemctl --user enable --now omarchy-update-user-notify.path -run_first_run_step "notify about pending migrations" omarchy-migrate-notify - run_first_run_step "install Voxtype post-update hook" \ omarchy-hook-install post-update "$OMARCHY_PATH/install/user/first-run/install-voxtype.hook" run_first_run_step "install fingerprint setup post-update hook" \ @@ -118,7 +81,7 @@ run_first_run_step "set GTK primary paste" \ run_first_run_step "apply speaker tuning" \ bash "$OMARCHY_PATH/install/user/first-run/audio-tuning.sh" -wait_for_notifications +omarchy-notification-wait || log_first_run "Timed out waiting for notification service; continuing" run_first_run_step "show welcome notification" \ bash "$OMARCHY_PATH/install/user/first-run/welcome.sh" # The first-run notification scripts register action callbacks in background diff --git a/bin/omarchy-migrate b/bin/omarchy-migrate index fc801e35..056a578d 100755 --- a/bin/omarchy-migrate +++ b/bin/omarchy-migrate @@ -96,6 +96,7 @@ while IFS=$'\t' read -r name file marker; do fi done < <(migration_entries) -# Clear notifications queued while an update was installing migrations. The -# substring matches both the current and legacy notification titles. +# Clear a login-time notification the user left sitting there and then resolved +# by running migrations some other way. The substring matches both the current +# and legacy notification titles. omarchy-notification-dismiss "Omarchy Migrations" >/dev/null 2>&1 || true diff --git a/bin/omarchy-migrate-notify b/bin/omarchy-migrate-notify index 8f7935b8..1f9fb541 100755 --- a/bin/omarchy-migrate-notify +++ b/bin/omarchy-migrate-notify @@ -15,11 +15,17 @@ fi notify_command=$(printf 'if [[ -n $(omarchy-notification-send -u critical -g  "Pending Omarchy Migrations" %q -a) ]]; then omarchy-launch-floating-terminal-with-presentation omarchy-migrate; fi' "$message") -if omarchy-cmd-present systemd-run; then - unit="omarchy-migrations-notification-$(date +%Y%m%d%H%M%S)" - systemd-run --user --scope --unit="$unit" bash -lc "$notify_command" >/dev/null 2>&1 && exit 0 -fi +# This runs from omarchy-migrate-notify.service at graphical-session.target, +# which the session can reach before the shell has claimed +# org.freedesktop.Notifications. Without the wait the toast is sent into the +# void and the user never learns about their pending migrations. +omarchy-notification-wait || true +unit="omarchy-migrations-notification-$(date +%Y%m%d%H%M%S)" +systemd-run --user --scope --unit="$unit" bash -lc "$notify_command" >/dev/null 2>&1 && exit 0 + +# Reached when there is no user manager to run the scope under, such as a +# non-graphical shell, so fall back to telling the user in the terminal. print_pending_migrations() { echo "Omarchy has pending migrations. Run omarchy-migrate in a terminal to apply them:" while IFS= read -r migration; do diff --git a/bin/omarchy-notification-wait b/bin/omarchy-notification-wait new file mode 100755 index 00000000..609dc326 --- /dev/null +++ b/bin/omarchy-notification-wait @@ -0,0 +1,30 @@ +#!/bin/bash + +# omarchy:summary=Wait for the desktop notification server to accept notifications +# omarchy:args=[timeout-seconds] +# omarchy:hidden=true + +set -uo pipefail + +timeout=${1:-10} + +notification_server_ready() { + busctl --user call \ + org.freedesktop.Notifications \ + /org/freedesktop/Notifications \ + org.freedesktop.Notifications \ + GetServerInformation >/dev/null 2>&1 +} + +# The shell has to be up to serve the IPC, and it has to have claimed the +# notification bus name before notify-send has anywhere to deliver. +attempts=$((timeout * 10)) +while (( attempts > 0 )); do + if omarchy-shell notifications ping >/dev/null 2>&1 && notification_server_ready; then + exit 0 + fi + attempts=$((attempts - 1)) + sleep 0.1 +done + +exit 1 diff --git a/default/systemd/user/omarchy-migrate-notify.service b/default/systemd/user/omarchy-migrate-notify.service new file mode 100644 index 00000000..79207652 --- /dev/null +++ b/default/systemd/user/omarchy-migrate-notify.service @@ -0,0 +1,16 @@ +[Unit] +Description=Notify about pending Omarchy migrations +# Login-only. There used to be an omarchy-update-user-notify.path watching +# /usr/share/omarchy/migrations, but pacman writes that directory during every +# update -- including the blessed `omarchy update`, which runs omarchy-migrate +# itself a step later -- so the watcher notified about migrations that were +# already being applied in the visible update terminal. Checking once per login +# is the only trigger that cannot collide with a running update. +ConditionPathIsDirectory=/usr/share/omarchy/migrations + +[Service] +Type=oneshot +ExecStart=/usr/bin/omarchy-migrate-notify + +[Install] +WantedBy=graphical-session.target diff --git a/default/systemd/user/omarchy-update-user-notify.path b/default/systemd/user/omarchy-update-user-notify.path deleted file mode 100644 index aaf5233f..00000000 --- a/default/systemd/user/omarchy-update-user-notify.path +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=Watch for Omarchy migrations - -[Path] -# Edge-triggered watch only. PathExistsGlob= is level-triggered: it re-fires -# every time the triggered unit deactivates for as long as the glob matches, -# and applied migrations stay on disk forever, so a glob here busy-loops the -# oneshot service. The once-per-login check lives in the service's own -# WantedBy=graphical-session.target instead. -PathModified=/usr/share/omarchy/migrations -Unit=omarchy-update-user-notify.service - -[Install] -WantedBy=graphical-session.target diff --git a/default/systemd/user/omarchy-update-user-notify.service b/default/systemd/user/omarchy-update-user-notify.service deleted file mode 100644 index 92f25a65..00000000 --- a/default/systemd/user/omarchy-update-user-notify.service +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=Notify about pending Omarchy migrations -ConditionPathIsDirectory=/usr/share/omarchy/migrations - -[Service] -Type=oneshot -ExecStart=/usr/bin/omarchy-migrate-notify - -[Install] -WantedBy=graphical-session.target diff --git a/docs/file-layout.md b/docs/file-layout.md index 2720d382..d612166a 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -197,13 +197,19 @@ migration. Migrations run as the user; privileged work should invoke the appropriate helper or privilege prompt. Migrations must be idempotent; machine-wide repairs should no-op when another user already applied them. -Each graphical user has `omarchy-update-user-notify.path` watching the packaged -migration directory for changes, and `omarchy-update-user-notify.service` is -also started once per login via its own `WantedBy=graphical-session.target`. -Either way the service runs `omarchy-migrate-notify` as that user. The notifier checks -`omarchy-migrate --pending`. If this user has missing migration state, it shows a -notification that opens a terminal for `omarchy-migrate`. The notifier never runs -migrations in the background. +Each graphical user has `omarchy-migrate-notify.service`, started once per login +through `WantedBy=graphical-session.target`. The package also ships +`omarchy-update-user-notify.service` as a symlink onto it, so users enabled +under the old unit name keep working before they reach migration `1785095882`. +It runs `omarchy-migrate-notify` as +that user, which checks `omarchy-migrate --pending`. If this user has missing +migration state, it shows a notification that opens a terminal for +`omarchy-migrate`. The notifier never runs migrations in the background. + +Login is the only trigger. Nothing watches the packaged migration directory: a +watcher cannot tell a bypassed `pacman -Syu` from the package transaction inside +a normal `omarchy update`, so it notified about migrations that `omarchy-migrate` +was already applying in the visible update terminal. `omarchy-migrate` waits for any active pacman transaction to finish, then runs pending migrations. It does not need `--force`; migrations happen when state @@ -221,8 +227,8 @@ systemd instance: Voxtype post-update hook. - `install/user/first-run/enable-user-units.sh` — `systemctl --user enable` the shipped user units (`bt-agent`, `omarchy-sleep-lock`, - `omarchy-recover-internal-monitor`, `omarchy-update-user-notify.path`, - `omarchy-update-user-notify.service`). Done here, not at finalize, because + `omarchy-recover-internal-monitor`, `omarchy-migrate-notify.service`). + Done here, not at finalize, because the user manager isn't reachable from the ISO chroot; `ConditionPath*` in the unit files keeps services inert when they don't apply. - `install/user/first-run/gnome-theme.sh`, diff --git a/docs/migrations.md b/docs/migrations.md index 48c2125b..7e6d5538 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -43,16 +43,9 @@ omarchy-hook post-update `omarchy-migrate` waits for any active pacman transaction to finish, then runs all pending migrations for the current user in the visible update terminal. -### During direct pacman updates +### At login -Raw `sudo pacman -Syu` is guarded. Users should normally run: - -```bash -omarchy update -``` - -If a user explicitly bypasses the guard, user sessions watch the packaged -migration directory and run a notifier. The notifier checks: +Every graphical login starts `omarchy-migrate-notify.service`, which checks: ```bash omarchy-migrate --pending @@ -67,6 +60,15 @@ omarchy-migrate The notifier never runs migrations silently in the background. +This is what covers users who did not run the update themselves: someone who +bypassed the pacman guard with `sudo env OMARCHY_ALLOW_DIRECT_PACMAN=1 pacman +-Syu`, and any second user on the machine, whose migration markers are per-user +and therefore still missing after another user updated. + +Login is the only trigger on purpose. Watching the packaged migration directory +also fires during a normal `omarchy update`, which prompts for migrations that +`omarchy-migrate` is about to run in the visible update terminal. + ### Manually Users can safely run: diff --git a/docs/update-process.md b/docs/update-process.md index 200f1ffc..b12f4f51 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -149,20 +149,37 @@ High-level flow: sudo pacman -Syu ├─ pre-transaction guard aborts and tells the user to run omarchy update └─ if explicitly bypassed, upgrades omarchy and related packages - └─ user session notices migration directory changes - ├─ omarchy-update-user-notify.path triggers, if enabled + └─ at that user's next login + ├─ omarchy-migrate-notify.service starts with graphical-session.target ├─ omarchy-migrate-notify checks omarchy-migrate --pending ├─ if this user has missing migration state, show notification └─ click opens terminal: omarchy-migrate ``` +Login is deliberately the only trigger. A watcher on the packaged migration +directory cannot distinguish a bypassed `pacman -Syu` from the package +transaction inside a normal `omarchy update`, so it fired notifications for +migrations that `omarchy-migrate` was about to apply in the visible update +terminal. The retired unit was `omarchy-update-user-notify.path`. + Fallbacks: -- `omarchy-first-run` enables the user notification path unit. -- `omarchy-first-run` also invokes `omarchy-migrate-notify` on graphical - startup, so users who updated before the path unit existed still get prompted - if they have missing migration state. +- `omarchy-first-run` enables `omarchy-migrate-notify.service`, which also + covers users created after install: their per-user migration markers are + missing, so their first login prompts them to run every shipped migration. +- The package ships `omarchy-update-user-notify.service` as a symlink onto + `omarchy-migrate-notify.service`. Users set up before the rename hold an + absolute `graphical-session.target.wants` symlink to the old path, and the + migration that repoints it only runs for users who run an update — the + opposite of who the notifier is for. The alias can be dropped once installs + have run migration `1785095882`. +- The notifier waits for a live notification server before sending, because + `graphical-session.target` can be reached before the shell claims + `org.freedesktop.Notifications`. - The notifier is only a prompt. It does not run migrations in the background. +- A session that is already open when another user updates is not re-checked; + it picks the migrations up at its next login, or whenever that user runs + `omarchy-migrate` or `omarchy update`. - Direct pacman updates do not run `omarchy-hook post-update` unless the user explicitly runs that hook; without a package-update marker, the only pending state we can derive is missing per-user migration markers. @@ -209,7 +226,7 @@ scripts. | `omarchy-update-system-pkgs` | Runs `sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm` with targeted transition `--overwrite` entries so the ALPM guard allows the transaction and early package-layout conflicts are handled. | **Keep for now.** Small leaf command, clear/testable. | | `omarchy-migrate` | Public migration command. Waits for pacman, then runs all pending migrations for the current user. Supports `--pending`. | **Keep.** This replaces the discarded `omarchy-update-user-finalize` name and no longer needs `--force`. | | `omarchy-update-pacman-guard` | ALPM pre-transaction guard that aborts direct `pacman -Syu` style upgrades unless Omarchy set `OMARCHY_UPDATE_PACMAN=1` or the user explicitly set `OMARCHY_ALLOW_DIRECT_PACMAN=1`. | **Keep internal/hidden.** This is what nudges users back to `omarchy update`. | -| `omarchy-migrate-notify` | Internal notification helper for direct pacman updates. Uses `omarchy-migrate --pending` and shows notification only when this user has pending migrations. | **Keep internal/hidden.** Clear name now that the public command is `omarchy-migrate`. | +| `omarchy-migrate-notify` | Internal login-time notification helper. Uses `omarchy-migrate --pending` and shows a notification only when this user has pending migrations. | **Keep internal/hidden.** Clear name now that the public command is `omarchy-migrate`. | | `omarchy-update-user-notify` | Hidden compatibility wrapper for `omarchy-migrate-notify`. | **Temporary.** Keep only for old callers. | | `omarchy-update-available` | Update checker for shell widget and post-update refresh. | **Keep.** Could eventually be renamed `omarchy-update-check`, but current name matches widget semantics. | | `omarchy-update-aur-pkgs` | Updates AUR packages with `yay -Sua` if foreign packages exist and AUR is reachable. | **Question.** Omarchy is package-backed now, but users may still install AUR packages. Keep for now. | @@ -229,7 +246,8 @@ scripts. idempotent when they repair machine-wide state. 2. **Migration notification naming** - - The real helper is `omarchy-migrate-notify`. + - The real helper is `omarchy-migrate-notify`, started by + `omarchy-migrate-notify.service`. - `omarchy-update-user-notify` remains only as a hidden compatibility wrapper. 3. **Update pipeline ownership** diff --git a/install/user/first-run/enable-user-units.sh b/install/user/first-run/enable-user-units.sh index efc8123c..a5d7cb8e 100755 --- a/install/user/first-run/enable-user-units.sh +++ b/install/user/first-run/enable-user-units.sh @@ -16,5 +16,4 @@ systemctl --user enable --now \ bt-agent.service \ omarchy-recover-internal-monitor.service \ omarchy-sleep-lock.service \ - omarchy-update-user-notify.path \ - omarchy-update-user-notify.service + omarchy-migrate-notify.service diff --git a/migrations/1785095882.sh b/migrations/1785095882.sh new file mode 100644 index 00000000..cb633bac --- /dev/null +++ b/migrations/1785095882.sh @@ -0,0 +1,39 @@ +echo "Only check for pending migrations at login, not on every package update" + +# omarchy-update-user-notify.path watched /usr/share/omarchy/migrations, but +# pacman writes that directory during every update -- including the blessed +# `omarchy update`, which runs omarchy-migrate a step later. The watcher fired a +# critical notification for migrations that were already being applied in the +# visible update terminal. Retire the watcher and keep only the once-per-login +# check, now named after the command it runs. + +wants_dir="$HOME/.config/systemd/user/graphical-session.target.wants" + +systemctl --user daemon-reload >/dev/null 2>&1 || true + +# The watcher's unit file is already gone, but it stays loaded in a session that +# started before this update, so stop it before it can fire again. +systemctl --user stop omarchy-update-user-notify.path >/dev/null 2>&1 || true + +# Enable the replacement before dropping the old enablement, so a failure here +# can never leave a user with no notifier at all. Enable without --now: this +# usually runs from inside `omarchy update`, and starting the notifier here would +# pop a toast for the migrations running right after it -- the exact behavior +# being removed. `systemctl enable` also needs a live user manager, which +# `omarchy update` over SSH does not have, so fall back to writing precisely the +# symlink it would have written rather than silently doing nothing. +if ! systemctl --user enable omarchy-migrate-notify.service >/dev/null 2>&1; then + mkdir -p "$wants_dir" + ln -sfn /usr/lib/systemd/user/omarchy-migrate-notify.service \ + "$wants_dir/omarchy-migrate-notify.service" +fi + +# Drop the retired enablement by hand instead of through `systemctl disable`. +# The package ships omarchy-update-user-notify.service as a compatibility +# symlink onto the new unit, for users who have not reached this migration yet, +# so disabling that name here would disable the replacement along with it. +rm -f "$wants_dir/omarchy-update-user-notify.path" \ + "$wants_dir/omarchy-update-user-notify.service" + +systemctl --user reset-failed omarchy-update-user-notify.path >/dev/null 2>&1 || true +systemctl --user daemon-reload >/dev/null 2>&1 || true diff --git a/test/shell.d/config-test.sh b/test/shell.d/config-test.sh index 905df673..ed1d7480 100755 --- a/test/shell.d/config-test.sh +++ b/test/shell.d/config-test.sh @@ -120,8 +120,7 @@ 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/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 +134,16 @@ for source, destination, legacy in package_defaults: if destination and (source not in pkgbuild or destination not in pkgbuild): errors.append(f"PKGBUILD does not explicitly install {source} -> {destination}") +# Existing users have an absolute wants symlink to the old unit path, and the +# migration that repoints it only runs for users who run an update -- the +# opposite of who the notifier is for. Dropping this alias strands them. +notify_alias = 'ln -sfn omarchy-migrate-notify.service "$pkgdir/usr/lib/systemd/user/omarchy-update-user-notify.service"' +if notify_alias not in pkgbuild: + errors.append( + "PKGBUILD does not ship the omarchy-update-user-notify.service compatibility " + "alias, so users who have not run migration 1785095882 lose the login notifier" + ) + alpm_hooks = [ "00-omarchy-update-guard.hook", "10-omarchy-hyprland-reload-pause.hook", diff --git a/test/shell.d/migrate-notify-test.sh b/test/shell.d/migrate-notify-test.sh index 9b182acb..a07888e9 100644 --- a/test/shell.d/migrate-notify-test.sh +++ b/test/shell.d/migrate-notify-test.sh @@ -33,6 +33,12 @@ bash -c "$command" SH chmod +x "$stub_bin/systemd-run" +cat >"$stub_bin/omarchy-notification-wait" <<'SH' +#!/bin/bash +exit 0 +SH +chmod +x "$stub_bin/omarchy-notification-wait" + cat >"$stub_bin/omarchy-notification-send" <<'SH' #!/bin/bash printf '%s\n' "$@" >"$OMARCHY_TEST_NOTIFY_ARGS" diff --git a/test/shell.d/systemd-test.sh b/test/shell.d/systemd-test.sh index cb9bd169..b6346495 100755 --- a/test/shell.d/systemd-test.sh +++ b/test/shell.d/systemd-test.sh @@ -28,12 +28,19 @@ grep -F 'ExecStart=/usr/bin/omarchy-system-sleep-monitor' "$upgrade_to_quattro" grep -F 'reset-failed omarchy-sleep-lock.service' "$upgrade_to_quattro" >/dev/null pass "Omarchy 4 upgrade repairs the legacy sleep lock unit path" -notify_path="$ROOT/default/systemd/user/omarchy-update-user-notify.path" -! grep -q 'PathExistsGlob' "$notify_path" -grep -Fx 'PathModified=/usr/share/omarchy/migrations' "$notify_path" >/dev/null -pass "migration watcher is edge-triggered so applied migrations on disk cannot re-trigger it" +[[ -e $ROOT/default/systemd/user/omarchy-update-user-notify.path ]] && + fail "the retired migration watcher is back; pacman writing the migration directory during omarchy update would notify about migrations that update is already applying" +grep -rlE '^(Path[A-Za-z]+|DirectoryNotEmpty)=.*/usr/share/omarchy/migrations' "$ROOT/default/systemd/user" >/dev/null 2>&1 && + fail "a user unit watches the migration directory again; the notifier must stay login-only" +pass "no unit watches the migration directory, so package updates cannot trigger the notifier" -notify_service="$ROOT/default/systemd/user/omarchy-update-user-notify.service" -! grep -q 'StartLimit' "$notify_service" +notify_service="$ROOT/default/systemd/user/omarchy-migrate-notify.service" +grep -Fx 'ExecStart=/usr/bin/omarchy-migrate-notify' "$notify_service" >/dev/null grep -Fx 'WantedBy=graphical-session.target' "$notify_service" >/dev/null -pass "migration notifier keeps its start-rate limit and still runs once per login" +pass "migration notifier only checks once per login" + +grep -F 'omarchy-migrate-notify.service' "$first_run_units" >/dev/null || + fail "first-run does not enable the login migration notifier" +grep -F 'omarchy-update-user-notify' "$first_run_units" >/dev/null && + fail "first-run still enables the retired notifier units" +pass "first-run enables the login-only migration notifier" From f375113fe32ad9d11435623c106f64127d89c742 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 14:27:50 -0700 Subject: [PATCH 08/19] Find the omarchy-pkgs checkout for PKGBUILD coverage The three hardcoded candidates are all relative to a sibling of the omarchy checkout, so a clone anywhere else, such as ~/Work/omacom/omarchy-pkgs, found nothing. The block then crashed on read_text() and the whole test exited on an unhandled traceback, which reads as an unrelated failure rather than a missing checkout. Add the omacom layouts, an OMARCHY_PKGS_PATH override taking either the checkout or its pkgbuilds directory, and a named failure listing where it looked. Co-Authored-By: Claude Opus 5 (1M context) --- test/shell.d/config-test.sh | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/test/shell.d/config-test.sh b/test/shell.d/config-test.sh index ed1d7480..65968e90 100755 --- a/test/shell.d/config-test.sh +++ b/test/shell.d/config-test.sh @@ -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(): From 3f18182ef3a2846f9197c8736c33a80b4ba5867a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 15:13:52 -0700 Subject: [PATCH 09/19] Use consistent firefox glyph --- default/omarchy/omarchy-menu.jsonc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 2e94e8dc..20134af2 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -129,7 +129,7 @@ "setup.default.browser.brave": {"icon":"󰖟","label":"Brave","when":"omarchy-cmd-present brave","checked":"[[ \"$(omarchy-default-browser)\" == \"brave\" ]]","action":"omarchy-default-browser brave"}, "setup.default.browser.brave-origin": {"icon":"󰖟","label":"Brave Origin","when":"omarchy-cmd-present brave-origin","checked":"[[ \"$(omarchy-default-browser)\" == \"brave-origin\" ]]","action":"omarchy-default-browser brave-origin"}, "setup.default.browser.edge": {"icon":"󰇩","label":"Edge","when":"omarchy-cmd-present microsoft-edge-stable","checked":"[[ \"$(omarchy-default-browser)\" == \"edge\" ]]","action":"omarchy-default-browser edge"}, - "setup.default.browser.firefox": {"icon":"󰈹","label":"Firefox","when":"omarchy-cmd-present firefox","checked":"[[ \"$(omarchy-default-browser)\" == \"firefox\" ]]","action":"omarchy-default-browser firefox"}, + "setup.default.browser.firefox": {"icon":"","label":"Firefox","when":"omarchy-cmd-present firefox","checked":"[[ \"$(omarchy-default-browser)\" == \"firefox\" ]]","action":"omarchy-default-browser firefox"}, "setup.default.browser.zen": {"icon":"󰖟","label":"Zen","when":"omarchy-cmd-present zen-browser","checked":"[[ \"$(omarchy-default-browser)\" == \"zen\" ]]","action":"omarchy-default-browser zen"}, "setup.default.terminal": {"icon":"","label":"Terminal"}, "setup.default.terminal.alacritty": {"icon":"","label":"Alacritty","when":"omarchy-cmd-present alacritty","checked":"[[ \"$(omarchy-default-terminal)\" == \"alacritty\" ]]","action":"omarchy-default-terminal alacritty"}, From d8397ee654b109d1f78edd463af4576f7e18e2bc Mon Sep 17 00:00:00 2001 From: Gavin Nugent Date: Sun, 26 Jul 2026 23:22:08 +0100 Subject: [PATCH 10/19] Merge pull request #6382 from 28allday/resolve-full-opacity Exempt DaVinci Resolve from default window opacity --- default/hypr/apps/davinci-resolve.lua | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/default/hypr/apps/davinci-resolve.lua b/default/hypr/apps/davinci-resolve.lua index ba81e12c..aba9414d 100644 --- a/default/hypr/apps/davinci-resolve.lua +++ b/default/hypr/apps/davinci-resolve.lua @@ -1,2 +1,8 @@ --- DaVinci Resolve dialog focus handling. -o.window(".*[Rr]esolve.*", { float = true, stay_focused = true }) +-- DaVinci Resolve window focus handling. Kept fully opaque: the default +-- translucency distorts colour-critical grading work. +o.window(".*[Rr]esolve.*", { + float = true, + stay_focused = true, + tag = "-default-opacity", + opacity = "1 1", +}) From ada53b090ed705a4353c9db19b970bddd0eb6aa3 Mon Sep 17 00:00:00 2001 From: Mwikala Kangwa <39342367+mwikala@users.noreply.github.com> Date: Sun, 26 Jul 2026 23:27:06 +0100 Subject: [PATCH 11/19] Anchor the webcam overlay to the recorded region, not the monitor (#6384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Anchor the webcam overlay to the recorded region, not the monitor Recording a single window on an ultrawide put the camera outside the frame. The overlay is placed twice, and neither placement knows what is being captured: the window rules in default/hypr/apps/webcam-overlay.lua position it from monitor_w/monitor_h, and omarchy-capture-webcam-resize recomputes the same corner from hyprctl monitors. Both resolve to the monitor's bottom-right, so a window anywhere but the far right of the display records without the camera in it, and dragging it into frame by hand is undone by the next resize. The geometry is already known. select_capture_target resolves the picked window to a region rectangle before start_webcam_overlay runs, and a bare click in smart mode snaps that rectangle to the window's own bounds. gpu-screen-recorder takes the region in the compositor's logical coordinate space, which is the same space window moves take, so the value needs no conversion to be reused as an anchor. Publish it for the resize helper, which becomes the single place that positions the overlay. It anchors to the region when one is recorded and falls back to the monitor otherwise, so full-monitor captures, the portal backend, and manual resizes outside a recording all keep their current behaviour. A malformed or empty file falls back the same way. Presets scale from the anchor's height rather than the monitor's, so the camera keeps its proportion of the frame instead of covering a small capture outright. A tall, narrow region cannot fit a preset derived from its height, so widths are capped to the space available and heights follow at the same 8:9 aspect. Placement happens inside start_webcam_overlay rather than after it returns. Correcting the position once that function had returned left the move adjacent to gpu-screen-recorder starting, and the camera was recorded sliding the last stretch into its corner over the opening frames. The overlay is waited for explicitly instead, positioned, and only then does capture start. Waiting for the map is what the blind second was partly guessing at, so the remainder is trimmed to hold the delay before capture where it was. Starting later is not free: it eats the opening words of whatever is being narrated. * Keep the webcam size ladder usable in a narrow region Capping each preset's width to the region separately collapsed small, medium and large onto the same width, so Super + Alt + [ and ] had nothing to step between, and integer division left small taller than medium. Cap the height the presets scale from instead, which shrinks the ladder as a whole and keeps the three sizes ordered and distinct. Cover the anchoring in the test suite: a region the camera follows, the fallback for an unreadable one, and the narrow-region ladder. Point XDG_RUNTIME_DIR at the test's own directory while doing so — the resize helper now reads a region file from there, and the existing geometry assertions would pick up a real one from a live recording. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: David Heinemeier Hansson Co-authored-by: Claude Opus 5 (1M context) --- bin/omarchy-capture-screenrecording | 20 +++++++++- bin/omarchy-capture-webcam-resize | 43 ++++++++++++++++++---- test/shell.d/screenrecording-test.sh | 55 ++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 10 deletions(-) diff --git a/bin/omarchy-capture-screenrecording b/bin/omarchy-capture-screenrecording index 49ffa04e..4db875d0 100755 --- a/bin/omarchy-capture-screenrecording +++ b/bin/omarchy-capture-screenrecording @@ -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="" diff --git a/bin/omarchy-capture-webcam-resize b/bin/omarchy-capture-webcam-resize index ebe9840f..cde61e08 100755 --- a/bin/omarchy-capture-webcam-resize +++ b/bin/omarchy-capture-webcam-resize @@ -8,6 +8,7 @@ set -euo pipefail readonly MARGIN=40 +readonly REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region" usage() { echo "Usage: omarchy-capture-webcam-resize " >&2 @@ -57,13 +58,39 @@ read -r monitor_x monitor_y monitor_width monitor_height < <( [[ $monitor_x =~ ^-?[0-9]+$ && $monitor_y =~ ^-?[0-9]+$ && $monitor_width =~ ^[0-9]+$ && $monitor_height =~ ^[0-9]+$ ]] || exit 0 -# Scale the 8:9 portrait presets from monitor height so they occupy the same +# Anchor to the recorded region when there is one, so a window picked on a wide +# display keeps the camera in its own corner. Full-monitor captures, the portal +# backend, and resizes outside a recording publish none and fall back here. +anchor_x=$monitor_x +anchor_y=$monitor_y +anchor_width=$monitor_width +anchor_height=$monitor_height + +if [[ -f $REGION_FILE ]] && region=$(<"$REGION_FILE"); then + if [[ $region =~ ^([0-9]+)x([0-9]+)\+(-?[0-9]+)\+(-?[0-9]+)$ ]]; then + anchor_width=${BASH_REMATCH[1]} + anchor_height=${BASH_REMATCH[2]} + anchor_x=${BASH_REMATCH[3]} + anchor_y=${BASH_REMATCH[4]} + fi +fi + +# A tall, narrow region can't fit presets scaled from its own height, so cap the +# height they scale from to what the width allows — the large preset is the +# widest at 3/10 of it. Scaling the ladder as a whole leaves small, medium and +# large distinct sizes for smaller and larger to step between. +scale_height=$anchor_height +available_width=$((anchor_width - 2 * MARGIN)) +((available_width > 0 && scale_height * 3 / 10 > available_width)) && + scale_height=$((available_width * 10 / 3)) + +# Scale the 8:9 portrait presets from that height so they occupy the same # proportion of a 1080p, HiDPI, ultrawide, or 6K recording. -small_height=$(((monitor_height * 9 + 25) / 50)) +small_height=$(((scale_height * 9 + 25) / 50)) small_width=$(((small_height * 8 + 4) / 9)) -medium_height=$(((monitor_height + 2) / 4)) +medium_height=$(((scale_height + 2) / 4)) medium_width=$(((medium_height * 8 + 4) / 9)) -large_height=$(((monitor_height * 27 + 40) / 80)) +large_height=$(((scale_height * 27 + 40) / 80)) large_width=$(((large_height * 8 + 4) / 9)) target_width=$current_width @@ -107,11 +134,11 @@ larger) ;; esac -target_x=$((monitor_x + monitor_width - target_width - MARGIN)) -target_y=$((monitor_y + monitor_height - target_height - MARGIN)) +target_x=$((anchor_x + anchor_width - target_width - MARGIN)) +target_y=$((anchor_y + anchor_height - target_height - MARGIN)) -((target_x < monitor_x + MARGIN)) && target_x=$((monitor_x + MARGIN)) -((target_y < monitor_y + MARGIN)) && target_y=$((monitor_y + MARGIN)) +((target_x < anchor_x + MARGIN)) && target_x=$((anchor_x + MARGIN)) +((target_y < anchor_y + MARGIN)) && target_y=$((anchor_y + MARGIN)) window="address:$address" hypr_dispatch \ diff --git a/test/shell.d/screenrecording-test.sh b/test/shell.d/screenrecording-test.sh index 238dc28b..cb8ca7e1 100644 --- a/test/shell.d/screenrecording-test.sh +++ b/test/shell.d/screenrecording-test.sh @@ -45,6 +45,8 @@ SH chmod +x "$stub_bin"/* export PATH="$stub_bin:$ROOT/bin:$PATH" +# The resize helper anchors to a region file here, so keep it out of the real one +export XDG_RUNTIME_DIR="$tmp_dir" export OMARCHY_TEST_MENU_ARGS="$tmp_dir/menu-args" export OMARCHY_TEST_RECORDER_ARGS="$tmp_dir/recorder-args" export OMARCHY_TEST_NOTIFICATION_ARGS="$tmp_dir/notification-args" @@ -153,6 +155,59 @@ if [[ -s $OMARCHY_TEST_HYPRCTL_ARGS ]]; then fi pass "webcam resize ignores other windows" +region_file="$XDG_RUNTIME_DIR/omarchy-screenrecord-region" + +: >"$OMARCHY_TEST_HYPRCTL_ARGS" +echo "800x600+100+100" >"$region_file" +"$ROOT/bin/omarchy-capture-webcam-resize" reset + +printf '%s\n' \ + 'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 133, y = 150 })' \ + 'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 727, y = 510 })' >"$expected_hyprctl_args" + +if ! cmp -s "$OMARCHY_TEST_HYPRCTL_ARGS" "$expected_hyprctl_args"; then + fail "webcam anchors to the recorded region" "$(diff -u "$expected_hyprctl_args" "$OMARCHY_TEST_HYPRCTL_ARGS")" +fi +pass "webcam anchors to the recorded region" + +printf '%s\n' \ + 'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 178, y = 200 })' \ + 'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 2342, y = 460 })' >"$expected_hyprctl_args" + +for region in "not-a-region" ""; do + : >"$OMARCHY_TEST_HYPRCTL_ARGS" + printf '%s' "$region" >"$region_file" + "$ROOT/bin/omarchy-capture-webcam-resize" reset + + if ! cmp -s "$OMARCHY_TEST_HYPRCTL_ARGS" "$expected_hyprctl_args"; then + fail "webcam falls back to the monitor for an unusable region" "$(diff -u "$expected_hyprctl_args" "$OMARCHY_TEST_HYPRCTL_ARGS")" + fi +done +pass "webcam falls back to the monitor for an unusable region" + +# A region too narrow for presets scaled from its height shrinks the whole +# ladder, so the three sizes stay distinct and each one fits inside the margins +: >"$OMARCHY_TEST_HYPRCTL_ARGS" +echo "200x1200+0+0" >"$region_file" +for size in small medium large; do + "$ROOT/bin/omarchy-capture-webcam-resize" "$size" +done + +printf '%s\n' \ + 'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 64, y = 72 })' \ + 'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 96, y = 1088 })' \ + 'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 89, y = 100 })' \ + 'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 71, y = 1060 })' \ + 'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 120, y = 135 })' \ + 'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 40, y = 1025 })' >"$expected_hyprctl_args" + +if ! cmp -s "$OMARCHY_TEST_HYPRCTL_ARGS" "$expected_hyprctl_args"; then + fail "webcam sizes stay distinct and inside a narrow region" "$(diff -u "$expected_hyprctl_args" "$OMARCHY_TEST_HYPRCTL_ARGS")" +fi +pass "webcam sizes stay distinct and inside a narrow region" + +rm -f "$region_file" + grep -F 'o.bind("SUPER + ALT + code:34", "Make webcam overlay smaller", "omarchy-capture-webcam-resize smaller")' \ "$ROOT/default/hypr/bindings/utilities.lua" >/dev/null || fail "webcam smaller hotkey is configured" grep -F 'o.bind("SUPER + ALT + code:35", "Make webcam overlay larger", "omarchy-capture-webcam-resize larger")' \ From d3fdaca1a94cbbf75ff38b37781cca31177cc855 Mon Sep 17 00:00:00 2001 From: Husam Date: Mon, 27 Jul 2026 03:39:04 +0300 Subject: [PATCH 12/19] Fix Arabic falling back to Nastaliq in Chromium and Electron apps (#6377) * Fix Arabic falling back to Nastaliq in Chromium and Electron apps * Cover the widened patterns too, and leave Urdu in Nastaliq The weak binding only won the last-resort race for patterns fontconfig had not widened. Anything whose family expands through the latin and nonlatin alias chains -- Arial, Helvetica, Verdana, sans-serif, and so the bulk of real CSS -- still resolved Arabic to Kufi, a display face that suits body text no better than Nastaliq did. A strong binding wins those too. Charset matching outranks family matching either way, so a font that does not cover the codepoint still cannot be pulled in: Latin, monospace, emoji, Nerd Font glyphs and CJK all resolve as before. The untargeted rule also captured Urdu, which #6322 had deliberately left alone by scoping itself to lang=ar. Urdu is conventionally set in Nastaliq, so name it for lang=ur ahead of the Naskh append. Appended rather than prepended, or it would displace the family the app asked for and render Latin text in an Urdu locale as Nastaliq. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Mb1gPTJ6s7QyQZck7Z6F9v --------- Co-authored-by: David Heinemeier Hansson Co-authored-by: Claude Opus 5 (1M context) --- default/fontconfig/conf.avail/50-omarchy.conf | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/default/fontconfig/conf.avail/50-omarchy.conf b/default/fontconfig/conf.avail/50-omarchy.conf index 95f4f508..eecf66e0 100644 --- a/default/fontconfig/conf.avail/50-omarchy.conf +++ b/default/fontconfig/conf.avail/50-omarchy.conf @@ -38,6 +38,34 @@ + + + + ur + + + Noto Nastaliq Urdu + + + + + + + Noto Naskh Arabic + + + system-ui From b018719338d7130ef0e0e625f15d8bab76ac6a5d Mon Sep 17 00:00:00 2001 From: husamemad Date: Wed, 22 Jul 2026 22:04:59 +0300 Subject: [PATCH 13/19] Prepend 'us' to kb_layout when non-Latin layout is set Choosing a non-Latin layout (e.g. ara, il, ru) via /etc/vconsole.conf leaves Hyprland unable to match Omarchy's Latin-keysym bindings, since Hyprland resolves bindings against the first layout in kb_layout only. Prepending 'us' when the detected layout doesn't already include it keeps shortcuts reachable, and activating grp:alts_toggle lets the user switch to their layout with Left Alt + Right Alt. --- default/hypr/input.lua | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/default/hypr/input.lua b/default/hypr/input.lua index 27f3184d..6fddb9e0 100644 --- a/default/hypr/input.lua +++ b/default/hypr/input.lua @@ -23,12 +23,24 @@ end local vconsole = read_vconsole() +local layout = vconsole.XKBLAYOUT or "us" +local variant = vconsole.XKBVARIANT or "" +local kb_options = "compose:caps,shift:both_capslock" + +-- Prepend 'us' so Omarchy's Latin-keysym bindings resolve — Hyprland matches +-- bindings against the first layout in kb_layout, not the currently active one. +if not (","..layout..","):find(",us,", 1, true) then + layout = "us," .. layout + variant = "," .. variant + kb_options = kb_options .. ",grp:alts_toggle" +end + hl.config({ input = { - kb_layout = vconsole.XKBLAYOUT or "us", - kb_variant = vconsole.XKBVARIANT or "", + kb_layout = layout, + kb_variant = variant, kb_model = "", - kb_options = "compose:caps,shift:both_capslock", + kb_options = kb_options, kb_rules = "", follow_mouse = 1, sensitivity = 0, From e232c99ffdbe3fb5517c1f89882cb29339b9b189 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 18:22:16 -0700 Subject: [PATCH 14/19] Only lead with us when the layout can't type Latin letters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepending us whenever the layout wasn't already us would have demoted every Latin layout — de, fr, dk — to a secondary group, so those users would have booted into a US layout they never asked for. Latin layouts resolve the default keysym bindings fine, so only the layouts that can't type Latin letters need us in front. That list already exists for the initramfs hook, and a test keeps the two copies in sync. Also match on the first layout rather than looking for us anywhere in the list: Hyprland resolves bindings against the leading entry, so "il,us" needed the fix just as much as "il" did. Co-Authored-By: Claude Opus 5 (1M context) --- default/hypr/input.lua | 26 ++++-- test/shell.d/hyprland-keyboard-layout-test.sh | 85 +++++++++++++++++++ 2 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 test/shell.d/hyprland-keyboard-layout-test.sh diff --git a/default/hypr/input.lua b/default/hypr/input.lua index 6fddb9e0..c3c7d43f 100644 --- a/default/hypr/input.lua +++ b/default/hypr/input.lua @@ -21,24 +21,32 @@ 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 layout = vconsole.XKBLAYOUT or "us" -local variant = vconsole.XKBVARIANT or "" +local kb_layout = vconsole.XKBLAYOUT or "us" +local kb_variant = vconsole.XKBVARIANT or "" local kb_options = "compose:caps,shift:both_capslock" --- Prepend 'us' so Omarchy's Latin-keysym bindings resolve — Hyprland matches --- bindings against the first layout in kb_layout, not the currently active one. -if not (","..layout..","):find(",us,", 1, true) then - layout = "us," .. layout - variant = "," .. variant +-- 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 = layout, - kb_variant = variant, + kb_layout = kb_layout, + kb_variant = kb_variant, kb_model = "", kb_options = kb_options, kb_rules = "", diff --git a/test/shell.d/hyprland-keyboard-layout-test.sh b/test/shell.d/hyprland-keyboard-layout-test.sh new file mode 100644 index 00000000..68626cc8 --- /dev/null +++ b/test/shell.d/hyprland-keyboard-layout-test.sh @@ -0,0 +1,85 @@ +#!/bin/bash + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +require_command lua + +resolved_input() { + OMARCHY_PATH="$ROOT" OMARCHY_VCONSOLE="${1-}" lua <<'LUA' +package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path + +local vconsole = os.getenv("OMARCHY_VCONSOLE") +local real_open = io.open + +io.open = function(path, mode) + if path ~= "/etc/vconsole.conf" then + return real_open(path, mode) + end + + if not vconsole then + return nil + end + + local file = io.tmpfile() + file:write(vconsole) + file:seek("set") + return file +end + +hl = { + config = function(config) + local input = config.input + print(("[%s] [%s] [%s]"):format(input.kb_layout, input.kb_variant, input.kb_options)) + end, +} + +o = { window = function() end } + +require("default.hypr.input") +LUA +} + +assert_input() { + local description="$1" + local expected="$2" + local actual + + if (( $# > 2 )); then + actual=$(resolved_input "$3") + else + actual=$(resolved_input) + fi + + [[ $actual == "$expected" ]] || + fail "$description" "expected: $expected"$'\n'"actual: $actual" + pass "$description" +} + +base_options="compose:caps,shift:both_capslock" +toggle_options="$base_options,grp:alts_toggle" + +assert_input "missing vconsole.conf falls back to us" "[us] [] [$base_options]" +assert_input "us layout passes through" "[us] [intl] [$base_options]" 'XKBLAYOUT=us +XKBVARIANT=intl +' +assert_input "latin layouts are left alone" "[de] [nodeadkeys] [$base_options]" 'XKBLAYOUT=de +XKBVARIANT=nodeadkeys +' +assert_input "non-latin layout gains us in front" "[us,ara] [,] [$toggle_options]" 'XKBLAYOUT=ara +' +assert_input "prepended us keeps variants aligned" "[us,ru] [,phonetic] [$toggle_options]" 'XKBLAYOUT=ru +XKBVARIANT=phonetic +' +assert_input "non-latin layout in front gains us even when us trails" "[us,il,us] [,] [$toggle_options]" 'XKBLAYOUT=il,us +' + +hooks_conf="$ROOT/etc/mkinitcpio.conf.d/omarchy_hooks.conf" +input_lua="$ROOT/default/hypr/input.lua" + +hooks_layouts=$(awk -F')' '/\) ;;$/ { gsub(/[[:space:]|]+/, "\n", $1); print $1 }' "$hooks_conf" | grep '^[a-z]\+$' | sort) +lua_layouts=$(sed -n '/^local non_latin_layouts =/,+1p' "$input_lua" | grep -o '"[^"]*"' | tr -d '"' | tr ' ' '\n' | grep '^[a-z]\+$' | sort) + +[[ -n $hooks_layouts ]] || fail "non-latin layout list is readable from omarchy_hooks.conf" +[[ $hooks_layouts == "$lua_layouts" ]] || + fail "non-latin layout lists stay in sync" "$(diff <(echo "$hooks_layouts") <(echo "$lua_layouts"))" +pass "non-latin layout lists stay in sync with the initramfs hook" From a6a13cf3e6b7f6dfbc8aa129dc6f337577a0a2fa Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 19:00:02 -0700 Subject: [PATCH 15/19] Replace the in-flight updatedb and read PRUNEPATHS out of any quoting The machines this targets are the ones with an updatedb already grinding through every snapshot, and `systemctl start` on an active unit is a no-op. updatedb reads /etc/updatedb.conf once at startup, so a run that began before the rewrite keeps burning CPU on the old config until it finishes. Restart the service instead: it's Type=oneshot and plocate builds into a temp db, so nothing is lost by replacing the run. Quotes are optional in updatedb.conf, so parse the existing paths out of whatever quoting the file uses and write the setting back in one canonical form. `PRUNEPATHS=/tmp` previously fell through to the append branch and got a second PRUNEPATHS line, which drops /tmp from the pruned set. Comparing whole paths rather than substrings also keeps a config that already prunes something like /var/lib/machines/.snapshots from being mistaken for one that prunes /.snapshots. Prefer $OMARCHY_PATH over the packaged copy when locating the config script, per docs/migrations.md, so the migration test exercises the checked-out script rather than whatever release is installed. Skip when neither exists: omarchy-migrate runs under set -e, so a missing script would take down every migration queued behind it. Co-Authored-By: Claude Opus 5 (1M context) --- docs/file-layout.md | 3 ++- install/config/locate.sh | 10 +++++++--- migrations/1784809451.sh | 16 +++++++-------- test/shell.d/locate-test.sh | 40 ++++++++++++++++++++++++++++++++++--- 4 files changed, 54 insertions(+), 15 deletions(-) diff --git a/docs/file-layout.md b/docs/file-layout.md index 18c4f0f3..9be59863 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -248,7 +248,8 @@ the legacy finalization marker from `~/.local/state/omarchy/` into `done/`. finalization. It sources: - `install/config/*.sh` — theme links, lockout limits, lockscreen PAM, - powerprofilesctl shebang fix, docker setup, service enablement, firewall. + powerprofilesctl shebang fix, docker setup, Snapper retention, locate + index tuning, service enablement, firewall. - `install/hardware/all.sh` via `omarchy-setup-hardware` — vendor- and device-specific kernel modules, udev rules, microcode, wireless regdom, ASUS / Framework / Intel / Apple / Lenovo quirks. diff --git a/install/config/locate.sh b/install/config/locate.sh index d532f5df..529fa1ab 100644 --- a/install/config/locate.sh +++ b/install/config/locate.sh @@ -14,9 +14,13 @@ fi # Snapper snapshots are nested subvolumes reached by plain directory # traversal, so without this updatedb indexes the system once per snapshot. -if ! grep -E '^PRUNEPATHS[[:space:]]*=' "$UPDATEDB_CONF_PATH" | grep -qF '/.snapshots'; then - if grep -qE '^PRUNEPATHS[[:space:]]*=[[:space:]]*"' "$UPDATEDB_CONF_PATH"; then - sed -i -E 's|^(PRUNEPATHS[[:space:]]*=[[:space:]]*")|\1/.snapshots |' "$UPDATEDB_CONF_PATH" +# The quotes are optional in updatedb.conf, so read the paths back out of +# whatever quoting the file uses and write the setting in one canonical form. +pruned=$(sed -nE 's|^PRUNEPATHS[[:space:]]*=[[:space:]]*"?([^"]*)"?[[:space:]]*$|\1|p' "$UPDATEDB_CONF_PATH" | tail -n 1) + +if [[ " $pruned " != *" /.snapshots "* ]]; then + if [[ -n $pruned ]]; then + sed -i -E "s|^PRUNEPATHS[[:space:]]*=.*|PRUNEPATHS = \"/.snapshots $pruned\"|" "$UPDATEDB_CONF_PATH" else printf '%s\n' 'PRUNEPATHS = "/.snapshots"' >>"$UPDATEDB_CONF_PATH" fi diff --git a/migrations/1784809451.sh b/migrations/1784809451.sh index 0cf9d1a0..fb59a794 100644 --- a/migrations/1784809451.sh +++ b/migrations/1784809451.sh @@ -1,11 +1,7 @@ echo "Configure locate to skip Btrfs snapshots and index Btrfs subvolumes" OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}" -locate_config_script=/usr/share/omarchy/install/config/locate.sh -if [[ ! -f $locate_config_script ]]; then - locate_config_script="$OMARCHY_PATH/install/config/locate.sh" -fi - +locate_config_script="$OMARCHY_PATH/install/config/locate.sh" UPDATEDB_CONF_PATH="${OMARCHY_UPDATEDB_CONF_PATH:-/etc/updatedb.conf}" as_root() { @@ -17,14 +13,18 @@ as_root() { } [[ -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 -qF '/.snapshots'; then + 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. -as_root systemctl start --no-block plocate-updatedb.service >/dev/null 2>&1 || true +# multi-hour runs on snapshot-heavy systems back into one-minute runs. Restart +# rather than start: the machines this targets are the ones with an updatedb +# already grinding through every snapshot, and a run that started before the +# rewrite keeps using the config it read at startup. +as_root systemctl restart --no-block plocate-updatedb.service >/dev/null 2>&1 || true diff --git a/test/shell.d/locate-test.sh b/test/shell.d/locate-test.sh index 700b5bd2..77446103 100644 --- a/test/shell.d/locate-test.sh +++ b/test/shell.d/locate-test.sh @@ -36,7 +36,7 @@ OMARCHY_UPDATEDB_CONF_PATH="$test_tmp/missing.conf" bash -euo pipefail "$config_ 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 "=" that the stock Arch file uses. +# 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" @@ -52,10 +52,28 @@ 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 -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" pass "locate config handles updatedb.conf written without spaces around =" +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 /tmp"' "$conf" || fail "locate config keeps the paths an unquoted PRUNEPATHS already prunes" +[[ $(grep -c '^PRUNEPATHS' "$conf") -eq 1 ]] || fail "locate config replaces an unquoted PRUNEPATHS instead of adding a second one" +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" +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" @@ -85,7 +103,7 @@ OMARCHY_UPDATEDB_CONF_PATH="$conf" \ 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 start --no-block plocate-updatedb.service' "$test_tmp/calls.log" || fail "locate migration rebuilds the locate index without blocking" +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" @@ -98,3 +116,19 @@ OMARCHY_UPDATEDB_CONF_PATH="$conf" \ [[ ! -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" From e57f3b286c92de3f3a655aa1c194e2e16f8c2021 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 19:07:11 -0700 Subject: [PATCH 16/19] Send and receive files with Taildrop (#6375) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Send files to a tailnet machine with Taildrop The panel gets a send button next to the copy one on every machine that Tailscale grades as a Taildrop target, and `s` does the same from the keyboard. Picking runs through the XDG portal chooser, so it looks like the file dialog every other app opens. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TJQJfHXXApUk6En8EZisHg * Save incoming Taildrop files and say so Linux keeps Taildrop files in the daemon's inbox until someone asks for them, so nothing arrived until you ran `tailscale file get` by hand. A user service now stages each delivery next to the downloads directory, hands it over under a free name, and announces it — with a preview when it's an image, and a click to open it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TJQJfHXXApUk6En8EZisHg * Float every portal dialog, not just the titled ones The portal only ever shows dialogs, and the title regex missed any chooser an app names something else — ours included. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TJQJfHXXApUk6En8EZisHg * Re-run the Taildrop enable now that the unit ships The unit was never installed to /usr/lib/systemd/user/, so the enable had nothing to act on and machines that already ran the migration carry a marker for a no-op. Rename it so they get a working pass, and report what systemctl says instead of a bare failure line. Co-Authored-By: Claude Opus 5 (1M context) * Wait for the file chooser on the connection that asked for it The portal answers a request with a Response signal directed at the connection that made it, and dbus-daemon delivers directed signals only to that connection. gdbus monitor registers with AddMatch rather than BecomeMonitor, so it never saw the reply: every pick left omarchy-file-select blocked on a read that could not arrive, taking omarchy-tailscale-send down with it before it reached either its notification or the transfer. Make the call and wait for the signal on one connection, and give up after ten minutes so an unanswered dialog cannot strand the caller. Drop the "Sending to" notification while here, so a send reports once. Co-Authored-By: Claude Opus 5 (1M context) * Mark Taildrop notifications with the panel's send glyph Co-Authored-By: Claude Opus 5 (1M context) * Stop a hung tailscale poll from freezing the panel Each poll is skipped while its own process is still running, so one that never exits leaves the panel showing whatever it last read, for good: the peer list keeps a woken machine missing, and opening the panel cannot help because open runs the same refresh that hits the same guard. Reap anything still running fifteen seconds after a refresh, well inside the thirty second interval, so the next tick starts clean. Co-Authored-By: Claude Opus 5 (1M context) * Place a moved widget where an added one lands 'move omarchy.media left' named a section, not a slot, but the section went through as an explicit target, which resolves a missing index by appending. The widget landed on the far end of the row instead of after the section anchor where 'add' puts it. Its test has never run: the assertion covering this went in four hours after an unrelated layout change had already stopped the file, and the runner stops the whole suite at the first failure. Co-Authored-By: Claude Opus 5 (1M context) * Keep the config test from failing on things it is not about The center layout assertion pinned the whole row, so parking the indicators left of the clock broke a test named for update sitting next to weather. Assert that adjacency instead. The package-defaults check reads PKGBUILDs from the omarchy-pkgs repo and blew up with a traceback wherever that is not a sibling checkout. Skip it when the checkout is absent, honour OMARCHY_PKGS_ROOT when it is somewhere else, and keep failing when it is present and wrong. Between them these stopped the suite eighty files early. Co-Authored-By: Claude Opus 5 (1M context) * Make the file chooser a Python command rather than a bash host for one The portal work was a heredoc wedged inside a bash script that existed only to parse two flags. Drop the host: argparse covers the flags, and the file says at the top why it is the one command here not written in bash. Co-Authored-By: Claude Opus 5 (1M context) * Tell a chooser that never opened apart from one that was dismissed Three fixes from review: The poll watchdog rearmed on every refresh, so a refresh interval shorter than its timeout — the setting goes down to five seconds — pushed the deadline ahead of a hung process forever. Arm it on the launch that needs watching and leave it alone. omarchy-file-select exited 1 both for nothing picked and for a chooser that could not run, and omarchy-tailscale-send read it through a process substitution, which drops the status anyway. A session bus that was not there looked exactly like someone changing their mind. Separate the two exits and read them with a command substitution. Delivery picked a free name and then renamed, which overwrites anything that takes the name in between. Link to the name instead: link(2) refuses one that is taken, so the check and the claim are the same step. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- bin/omarchy | 2 + bin/omarchy-bar-plugin | 11 +- bin/omarchy-file-select | 107 ++++++++++++++++++ bin/omarchy-install-service-tailscale | 3 + bin/omarchy-remove-service-tailscale | 1 + bin/omarchy-tailscale-receive | 101 +++++++++++++++++ bin/omarchy-tailscale-send | 50 ++++++++ default/hypr/apps/system.lua | 6 +- .../user/omarchy-tailscale-receive.service | 12 ++ migrations/1785101000.sh | 11 ++ shell/plugins/panels/tailscale/Model.js | 29 +++++ shell/plugins/panels/tailscale/Panel.qml | 19 ++++ shell/plugins/panels/tailscale/README.md | 11 ++ shell/plugins/panels/tailscale/Service.qml | 56 ++++++++- test/shell.d/config-test.sh | 13 ++- test/shell.d/tailscale-receive-test.sh | 97 ++++++++++++++++ test/shell.d/tailscale-test.sh | 26 ++++- 17 files changed, 540 insertions(+), 15 deletions(-) create mode 100755 bin/omarchy-file-select create mode 100755 bin/omarchy-tailscale-receive create mode 100755 bin/omarchy-tailscale-send create mode 100644 default/systemd/user/omarchy-tailscale-receive.service create mode 100644 migrations/1785101000.sh create mode 100644 test/shell.d/tailscale-receive-test.sh diff --git a/bin/omarchy b/bin/omarchy index 0f50ccc7..57910d51 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -45,6 +45,7 @@ GROUP_DESCRIPTIONS[dev]="Omarchy development tools" GROUP_DESCRIPTIONS[display]="Display and text scaling" GROUP_DESCRIPTIONS[dns]="DNS resolver configuration" GROUP_DESCRIPTIONS[drive]="Drive selection and encryption" +GROUP_DESCRIPTIONS[file]="File selection helpers" GROUP_DESCRIPTIONS[font]="Font management" GROUP_DESCRIPTIONS[games]="Game launchers and helpers" GROUP_DESCRIPTIONS[hibernation]="Hibernation setup and removal" @@ -78,6 +79,7 @@ GROUP_DESCRIPTIONS[snapshot]="System snapshots" GROUP_DESCRIPTIONS[style]="Global UI style controls" GROUP_DESCRIPTIONS[sudo]="Sudo configuration helpers" GROUP_DESCRIPTIONS[system]="System status, reboot, shutdown, logout, and lock" +GROUP_DESCRIPTIONS[tailscale]="Tailscale helpers" GROUP_DESCRIPTIONS[theme]="Theme management" GROUP_DESCRIPTIONS[tmux]="Tmux session helpers" GROUP_DESCRIPTIONS[toggle]="Toggle Omarchy features" diff --git a/bin/omarchy-bar-plugin b/bin/omarchy-bar-plugin index c7442f10..58c89692 100755 --- a/bin/omarchy-bar-plugin +++ b/bin/omarchy-bar-plugin @@ -288,6 +288,15 @@ cmd_move() { fi local default_section="${PLACEMENT_SECTION:-}" + + # A bare section names the section, not the slot, so let it fall through to + # the same anchor placement 'add' uses. Passing it as an explicit target + # instead drops the widget on the far end of the row. + local target_section="$PLACEMENT_SECTION" + if [[ -z $PLACEMENT_INDEX && -z $PLACEMENT_BEFORE && -z $PLACEMENT_AFTER ]]; then + target_section="" + fi + local prog prog=$(cat <] [--multiple] +# omarchy:examples=omarchy file select --title "Send with Tailscale" --multiple + +# Python rather than bash, alone among the commands here, because the portal +# answers a request with a Response signal addressed to the connection that +# asked, and D-Bus delivers a directed signal only to that connection. Every +# shell-callable client — gdbus call, busctl call, dbus-send — opens its own +# connection and exits before the answer arrives, and gdbus monitor registers +# with AddMatch rather than BecomeMonitor, so it never sees one either. Holding +# a single connection across both the call and the wait is the whole job, and +# bash has no way to hold one. + +import argparse +import os +import sys + +import gi + +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib + +# A dialog nobody ever answers would otherwise keep this process, and whatever +# waits on its output, alive forever. +ANSWER_TIMEOUT_SEC = 600 + +# Callers act on these: nothing picked is a decision, a chooser that never ran +# is a fault, and the two want different handling. +EXIT_NOTHING_PICKED = 1 +EXIT_CHOOSER_FAILED = 2 + + +def main(): + parser = argparse.ArgumentParser(add_help=False) + parser.add_argument("--title", default="Select file") + parser.add_argument("--multiple", action="store_true") + args, unknown = parser.parse_known_args() + + if unknown: + print("omarchy-file-select: unknown option %s" % unknown[0], file=sys.stderr) + return EXIT_CHOOSER_FAILED + + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + loop = GLib.MainLoop() + uris = [] + + def on_response(connection, sender, path, interface, signal, params): + code, results = params.unpack() + if code == 0: + uris.extend(results.get("uris", [])) + loop.quit() + + def subscribe(path): + bus.signal_subscribe( + "org.freedesktop.portal.Desktop", + "org.freedesktop.portal.Request", + "Response", + path, + None, + Gio.DBusSignalFlags.NONE, + on_response, + ) + + # The request path is derived from our bus name and the token we pass, so it + # can be subscribed to up front. Asking first would race a dialog that gets + # answered immediately. + token = "omarchy%d" % os.getpid() + sender = bus.get_unique_name()[1:].replace(".", "_") + predicted = "/org/freedesktop/portal/desktop/request/%s/%s" % (sender, token) + subscribe(predicted) + + handle = bus.call_sync( + "org.freedesktop.portal.Desktop", + "/org/freedesktop/portal/desktop", + "org.freedesktop.portal.FileChooser", + "OpenFile", + GLib.Variant("(ssa{sv})", ("", args.title, { + "handle_token": GLib.Variant("s", token), + "multiple": GLib.Variant("b", args.multiple), + })), + None, + Gio.DBusCallFlags.NONE, + -1, + None, + ).unpack()[0] + + # Portals predating the token convention answer on a path of their choosing. + if handle != predicted: + subscribe(handle) + + GLib.timeout_add_seconds(ANSWER_TIMEOUT_SEC, loop.quit) + loop.run() + + for uri in uris: + print(GLib.filename_from_uri(uri)[0]) + + return 0 if uris else EXIT_NOTHING_PICKED + + +if __name__ == "__main__": + try: + sys.exit(main()) + except GLib.Error as error: + print("omarchy-file-select: %s" % error.message, file=sys.stderr) + sys.exit(EXIT_CHOOSER_FAILED) diff --git a/bin/omarchy-install-service-tailscale b/bin/omarchy-install-service-tailscale index 8adffc53..58dd28b5 100755 --- a/bin/omarchy-install-service-tailscale +++ b/bin/omarchy-install-service-tailscale @@ -13,6 +13,9 @@ sudo tailscale up --accept-routes echo -e "\nAllowing $USER to manage Tailscale..." sudo tailscale set --operator="$USER" +echo -e "\nReceiving Taildrop files in $HOME/Downloads..." +systemctl --user enable --now omarchy-tailscale-receive.service + echo -e "\nAdding Tailscale to the bar..." omarchy-bar-plugin add omarchy.tailscale diff --git a/bin/omarchy-remove-service-tailscale b/bin/omarchy-remove-service-tailscale index acda106e..adbe2103 100644 --- a/bin/omarchy-remove-service-tailscale +++ b/bin/omarchy-remove-service-tailscale @@ -4,6 +4,7 @@ # omarchy:requires-sudo=true tailscale down 2>/dev/null || true +systemctl --user disable --now omarchy-tailscale-receive.service 2>/dev/null || true sudo systemctl disable --now tailscaled.service 2>/dev/null || true omarchy-bar-plugin remove omarchy.tailscale omarchy-webapp-remove "Tailscale" 2>/dev/null || true diff --git a/bin/omarchy-tailscale-receive b/bin/omarchy-tailscale-receive new file mode 100755 index 00000000..6232646e --- /dev/null +++ b/bin/omarchy-tailscale-receive @@ -0,0 +1,101 @@ +#!/bin/bash + +# omarchy:summary=Save incoming Taildrop files and announce them +# omarchy:args=[--once] [directory] +# omarchy:examples=omarchy tailscale receive | omarchy tailscale receive --once ~/Desktop + +set -euo pipefail + +once=false +if [[ ${1:-} == "--once" ]]; then + once=true + shift +fi + +dir="${1:-${XDG_DOWNLOAD_DIR:-$HOME/Downloads}}" + +# Taildrop lands in a staging directory next door rather than straight in the +# downloads directory: waiting for a delivery can take hours, and everything +# else that shows up meanwhile is somebody else's file. Same filesystem, so +# handing the finished file over is a rename. +staging="$dir/.omarchy-taildrop" +mkdir -p "$staging" + +# Take the name by linking to it rather than by looking and then renaming. +# link(2) refuses an existing name, so nothing can land on the chosen one in +# the gap between the two. Staging shares the filesystem with the downloads +# directory, so the link always resolves and unlinking the staged name +# finishes the move. Prints the name it took. +claim_path() { + local staged="$1" name="${staged##*/}" base ext candidate index=0 + + base="${name%.*}" + ext="${name#"$base"}" + [[ -z $base ]] && { base="$name"; ext=""; } + + while (( index < 1000 )); do + if (( index == 0 )); then + candidate="$dir/$name" + else + candidate="$dir/$base-$index$ext" + fi + + if ln -- "$staged" "$candidate" 2>/dev/null; then + rm -f -- "$staged" + printf '%s\n' "$candidate" + return 0 + fi + + # Only a taken name is worth another spin. Anything else failed the link + # itself, and the file keeps its place in staging for the next run. + [[ -e $candidate ]] || return 1 + + ((index++)) + done + + return 1 +} + +announce() { + local path="$1" + local name="${path##*/}" + local args=("Received $name" "Saved to ${dir/#$HOME/~}") + + case "${name,,}" in + *.png | *.jpg | *.jpeg | *.gif | *.webp | *.avif | *.bmp | *.tif | *.tiff) + args+=(--image "$path") + ;; + *) + args+=(-g 󰒊) + ;; + esac + + # Clicking the notification opens the file, so this waits for the toast to + # go away. Callers background it to keep receiving in the meantime. + if [[ -n $(omarchy-notification-send "${args[@]}" -a) ]]; then + xdg-open "$path" + fi +} + +deliver() { + local staged target + + while IFS= read -r staged; do + target=$(claim_path "$staged") || continue + announce "$target" & + done < <(find "$staging" -mindepth 1 -maxdepth 1) +} + +# Anything left staged by an interrupted run still deserves delivering. +deliver + +while true; do + if ! tailscale file get --wait --conflict=rename "$staging"; then + $once && exit 1 + sleep 10 + continue + fi + + deliver + $once && exit 0 +done diff --git a/bin/omarchy-tailscale-send b/bin/omarchy-tailscale-send new file mode 100755 index 00000000..84093cc8 --- /dev/null +++ b/bin/omarchy-tailscale-send @@ -0,0 +1,50 @@ +#!/bin/bash + +# omarchy:summary=Send files to a machine on your tailnet with Taildrop +# omarchy:args= [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 [file...]" >&2 + exit 1 +fi + +machine="$1" +shift + +# Address the machine by whatever name we were handed, but talk about it by +# its short name, so a MagicDNS name does not spill into every message. +name="${machine%%.*}" + +files=("$@") + +if ((${#files[@]} == 0)); then + # Command substitution so the chooser's exit status survives: reading it + # through a process substitution reports success for a chooser that never + # opened, which is indistinguishable here from someone deciding not to send. + picked=$(omarchy-file-select --title "Send to $name" --multiple) || status=$? + + if ((${status:-0} > 1)); then + omarchy-notification-send -g "󰒊" -u critical "Could not send to $name" \ + "The file chooser did not open" + exit 1 + fi + + readarray -t files <<<"$picked" + [[ -n $picked ]] || exit 0 +fi + +if ((${#files[@]} == 1)); then + what=$(basename "${files[0]}") +else + what="${#files[@]} files" +fi + +if error=$(tailscale file cp --update-interval=0 -- "${files[@]}" "$machine:" 2>&1); then + omarchy-notification-send -g "󰒊" "Sent to $name" "$what" +else + omarchy-notification-send -g "󰒊" -u critical "Could not send to $name" "${error:-Taildrop transfer failed}" + exit 1 +fi diff --git a/default/hypr/apps/system.lua b/default/hypr/apps/system.lua index a7206550..a8c9b250 100644 --- a/default/hypr/apps/system.lua +++ b/default/hypr/apps/system.lua @@ -9,8 +9,12 @@ o.window( tag = "+floating-window", } ) +-- The portal only ever shows dialogs — file pickers, screen shares, permission +-- prompts — so every one of its windows belongs in the floating treatment, +-- whatever the app that asked for it titled it. +o.window("xdg-desktop-portal-gtk", { tag = "+floating-window" }) o.window({ - class = "(xdg-desktop-portal-gtk|sublime_text|DesktopEditors|org.gnome.Nautilus)", + class = "(sublime_text|DesktopEditors|org.gnome.Nautilus)", title = "^(Open.*Files?|Open [F|f]older.*|Save.*Files?|Save.*As|Save|All Files|.*wants to [open|save].*|[C|c]hoose.*)", }, { tag = "+floating-window" }) o.window("dev.tensaku.Tensaku", { float = true }) diff --git a/default/systemd/user/omarchy-tailscale-receive.service b/default/systemd/user/omarchy-tailscale-receive.service new file mode 100644 index 00000000..040af0b7 --- /dev/null +++ b/default/systemd/user/omarchy-tailscale-receive.service @@ -0,0 +1,12 @@ +[Unit] +Description=Save incoming Taildrop files to the downloads directory +ConditionPathExists=/usr/bin/tailscale + +[Service] +Type=simple +ExecStart=/usr/bin/omarchy-tailscale-receive +Restart=always +RestartSec=5 + +[Install] +WantedBy=graphical-session.target diff --git a/migrations/1785101000.sh b/migrations/1785101000.sh new file mode 100644 index 00000000..2b5aef76 --- /dev/null +++ b/migrations/1785101000.sh @@ -0,0 +1,11 @@ +echo "Save incoming Taildrop files to ~/Downloads" + +if omarchy-cmd-present tailscale; then + systemctl --user daemon-reload >/dev/null 2>&1 || true + + # Report what systemctl actually said; "could not enable" on its own gives + # nothing to act on. + if ! error=$(systemctl --user enable --now omarchy-tailscale-receive.service 2>&1); then + echo "Could not enable omarchy-tailscale-receive.service: $error" + fi +fi diff --git a/shell/plugins/panels/tailscale/Model.js b/shell/plugins/panels/tailscale/Model.js index 4153067c..bddf7153 100644 --- a/shell/plugins/panels/tailscale/Model.js +++ b/shell/plugins/panels/tailscale/Model.js @@ -73,10 +73,35 @@ function loginPlan(needsLogin, authUrl) { return { authUrl: "", command: ["tailscale", "up"] } } +// Taildrop is a tailnet feature the admin can turn off, so the button for it +// only makes sense when this profile actually carries the capability. +function hasFileSharing(self) { + var capability = "https://tailscale.com/cap/file-sharing" + var capMap = (self && self.CapMap) || null + if (capMap && capMap[capability] !== undefined) return true + var capabilities = (self && self.Capabilities) || [] + for (var i = 0; i < capabilities.length; i++) { + if (String(capabilities[i]) === capability) return true + } + return false +} + +// Tailscale grades every peer itself — offline, wrong owner, an OS without +// Taildrop, no peer API — so take its word when the status carries one, and +// fall back to same-owner for daemons too old to say. +function isTaildropTarget(peer, selfUserId) { + var target = peer && peer.TaildropTarget + if (typeof target === "number" && target !== 0) return target === 1 + var owner = String((peer && peer.UserID) || "") + return owner !== "" && owner === String(selfUserId || "") +} + function peerFromStatus(id, peer) { return { id: id, HostName: displayHostName(peer.HostName, peer.DNSName), + UserID: String(peer.UserID || ""), + TaildropTarget: typeof peer.TaildropTarget === "number" ? peer.TaildropTarget : 0, DNSName: cleanDnsName(peer.DNSName), DisplayName: displayHostName(peer.HostName, peer.DNSName), TailscaleIPs: filterIPv4(peer.TailscaleIPs || []), @@ -235,6 +260,8 @@ function parseStatus(raw) { selfName: displayHostName(self.HostName, self.DNSName), selfDnsName: cleanDnsName(self.DNSName), selfIp: selfIps.length > 0 ? selfIps[0] : "", + selfUserId: String(self.UserID || ""), + fileSharing: hasFileSharing(self), peers: peers, exitNodes: exitNodes } @@ -285,6 +312,8 @@ if (typeof module !== "undefined") { osIcon: osIcon, accountLabel: accountLabel, loginPlan: loginPlan, + hasFileSharing: hasFileSharing, + isTaildropTarget: isTaildropTarget, isMullvadPeer: isMullvadPeer, peerFromStatus: peerFromStatus, parseExitNodeList: parseExitNodeList, diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml index f188ca76..3202a686 100644 --- a/shell/plugins/panels/tailscale/Panel.qml +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -295,6 +295,13 @@ Panel { scrollCursorIntoView() } + // The file picker takes over from here, so get the panel out of the way. + function sendPeerFile(peer) { + if (!tailscale.canSendFiles(peer)) return + tailscale.sendFile(peer) + close() + } + function openSelectedPeerCopyMenu() { if (!peerColumn || peerIndex < 0 || peerIndex >= peerColumn.children.length) return var item = peerColumn.children[peerIndex] @@ -416,6 +423,7 @@ Panel { else if (t === "c" || t === "C") tailscale.copyPeerIp(root.selectedPeer()) else if (t === "n" || t === "N") tailscale.copyPeerName(root.selectedPeer()) else if (t === "d" || t === "D") tailscale.copyPeerDnsName(root.selectedPeer()) + else if (t === "s" || t === "S") root.sendPeerFile(root.selectedPeer()) } Flickable { @@ -973,6 +981,17 @@ Panel { } } + PanelActionButton { + id: sendButton + visible: tailscale.canSendFiles(peerRow.peer) + iconText: "󰒊" + tooltipText: "Send files" + foreground: root.foreground + fontFamily: root.fontFamily + Layout.alignment: Qt.AlignVCenter + onClicked: root.sendPeerFile(peerRow.peer) + } + PanelActionButton { id: copyButton iconText: "󰆏" diff --git a/shell/plugins/panels/tailscale/README.md b/shell/plugins/panels/tailscale/README.md index cb54f50a..5d542d4f 100644 --- a/shell/plugins/panels/tailscale/README.md +++ b/shell/plugins/panels/tailscale/README.md @@ -10,6 +10,7 @@ Native Omarchy bar widget for Tailscale. - Switch between available Tailscale connections when multiple are available - Browse machines from `tailscale status --json` - Copy a machine's Tailscale IP, host name, or DNS name +- Send files to a machine with Taildrop, when the tailnet allows file sharing ## Keyboard shortcuts @@ -20,6 +21,7 @@ Inside the panel: - `c`: copy selected peer IP - `n`: copy selected peer name - `d`: copy selected peer DNS name +- `s`: send files to selected peer - `t`: toggle Tailscale - `r`: refresh status - `esc`: close @@ -28,6 +30,15 @@ Inside the panel: - `tailscale` CLI on `PATH` - `wl-copy` for clipboard copy actions +- Taildrop enabled for the tailnet, to send files + +## Receiving files + +Incoming Taildrop files are saved to `~/Downloads` by the +`omarchy-tailscale-receive` service, which announces each one with a +notification (an image preview when the file is an image, and a click to open +it). The Tailscale service install enables it; `omarchy tailscale receive` +runs the same loop by hand. ## Icon diff --git a/shell/plugins/panels/tailscale/Service.qml b/shell/plugins/panels/tailscale/Service.qml index ffb5eb17..13fbfbc5 100644 --- a/shell/plugins/panels/tailscale/Service.qml +++ b/shell/plugins/panels/tailscale/Service.qml @@ -24,6 +24,8 @@ Item { property string selfName: "" property string selfDnsName: "" property string selfIp: "" + property string selfUserId: "" + property bool fileSharing: false property string authUrl: "" property var peers: [] property var exitNodes: [] @@ -123,6 +125,26 @@ Item { copyToClipboard(cleanDnsName(peer.DNSName), displayHostName(peer.HostName, peer.DNSName) + " DNS name") } + function peerAddress(peer) { + if (!peer) return "" + if (peer.DNSName) return cleanDnsName(peer.DNSName) + if (peer.HostName) return String(peer.HostName) + var ips = filterIPv4(peer.TailscaleIPs || []) + return ips.length > 0 ? ips[0] : "" + } + + function canSendFiles(peer) { + if (!fileSharing || !running || !peer) return false + return Model.isTaildropTarget(peer, selfUserId) + } + + function sendFile(peer) { + if (!canSendFiles(peer)) return + var target = peerAddress(peer) + if (target === "") return + Quickshell.execDetached(["omarchy-tailscale-send", target]) + } + function refresh(forceAccounts) { if (installed) { refreshStatusAndAccounts(forceAccounts === true) @@ -137,18 +159,21 @@ Item { function refreshStatusAndAccounts(forceAccounts) { if (!installed) return + var launched = false if (!statusProcess.running) { _statusOutput = "" _statusError = "" refreshing = true statusProcess.command = ["tailscale", "status", "--json"] statusProcess.running = true + launched = true } if (!mullvadExitNodesProcess.running) { _mullvadExitNodesOutput = "" _mullvadExitNodesError = "" mullvadExitNodesProcess.command = ["tailscale", "exit-node", "list"] mullvadExitNodesProcess.running = true + launched = true } var now = Date.now() var shouldRefreshAccounts = forceAccounts === true || accounts.length === 0 || now - _lastAccountsRefreshMs > 60000 @@ -158,7 +183,13 @@ Item { _lastAccountsRefreshMs = now accountsProcess.command = ["tailscale", "switch", "--list", "--json"] accountsProcess.running = true + launched = true } + // Arm on the launch that needs watching and leave it alone after that. + // Restarting it every refresh pushes the deadline out ahead of a hung + // process forever once the refresh interval is shorter than the timeout, + // and refreshIntervalSec goes down to five seconds. + if (launched && !pollWatchdog.running) pollWatchdog.start() } function elideStatus(text) { @@ -175,6 +206,8 @@ Item { selfName = "" selfDnsName = "" selfIp = "" + selfUserId = "" + fileSharing = false authUrl = "" peers = [] exitNodes = [] @@ -212,6 +245,8 @@ Item { selfName = parsed.selfName selfDnsName = parsed.selfDnsName selfIp = parsed.selfIp + selfUserId = parsed.selfUserId + fileSharing = parsed.fileSharing peers = parsed.running ? parsed.peers : [] tailnetExitNodes = parsed.running ? parsed.exitNodes : [] exitNodes = parsed.running ? tailnetExitNodes.concat(mullvadRegions) : [] @@ -295,10 +330,7 @@ Item { var mullvadIps = filterIPv4(peer.TailscaleIPs || []) if (mullvadIps.length > 0) return mullvadIps[0] } - if (peer.DNSName) return cleanDnsName(peer.DNSName) - if (peer.HostName) return String(peer.HostName) - var ips = filterIPv4(peer.TailscaleIPs || []) - return ips.length > 0 ? ips[0] : "" + return peerAddress(peer) } function setExitNode(peer) { @@ -386,6 +418,22 @@ Item { onTriggered: root.refresh() } + Timer { + // Every poll is skipped while its own process is still running, so one that + // never exits — tailscale can hang on a network that is coming and going — + // silently stops the panel refreshing at all, and it stays stopped. Reap + // anything still running well inside the refresh interval so the next tick + // starts clean. + id: pollWatchdog + interval: 15000 + repeat: false + onTriggered: { + if (statusProcess.running) statusProcess.running = false + if (mullvadExitNodesProcess.running) mullvadExitNodesProcess.running = false + if (accountsProcess.running) accountsProcess.running = false + } + } + Timer { id: actionStatusTimer interval: 2200 diff --git a/test/shell.d/config-test.sh b/test/shell.d/config-test.sh index 65968e90..14ab4b30 100755 --- a/test/shell.d/config-test.sh +++ b/test/shell.d/config-test.sh @@ -16,14 +16,14 @@ pass "default shell.json is valid JSON" jq -e '.version == 1 and (.bar.layout.left | type == "array") and (.bar.layout.center | type == "array") and (.bar.layout.right | type == "array")' "$ROOT/config/omarchy/shell.json" >/dev/null pass "default shell.json has versioned bar layout" +# Pinning the whole row made this fail every time an unrelated widget moved, +# so assert the adjacency the name is about and let the rest of the row change. jq -e ' def ids: map(.id // .); - .bar.layout.center | ids == [ - "omarchy.clock", - "omarchy.weather", - "omarchy.system-update", - "omarchy.indicators" - ] + (.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" @@ -138,6 +138,7 @@ package_defaults = [ ("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-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"), diff --git a/test/shell.d/tailscale-receive-test.sh b/test/shell.d/tailscale-receive-test.sh new file mode 100644 index 00000000..4b59afed --- /dev/null +++ b/test/shell.d/tailscale-receive-test.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +WORKDIR=$(mktemp -d) +cleanup() { rm -rf "$WORKDIR"; } +trap cleanup EXIT + +downloads="$WORKDIR/downloads" +mkdir -p "$WORKDIR/bin" "$downloads" "$WORKDIR/outbox" +printf 'mine' >"$downloads/unrelated.txt" + +# Stands in for the daemon handing over whatever is waiting in the inbox. A +# decoy is whatever else drops into the downloads directory while Taildrop is +# still blocking on the next delivery. +cat >"$WORKDIR/bin/tailscale" <"$downloads/\$DECOY" +mv "$WORKDIR/outbox/"* "\$target/" +SH + +cat >"$WORKDIR/bin/omarchy-notification-send" <>"$WORKDIR/notifications" +# Only the photo notification gets clicked. +[[ \$* == *photo.png* ]] && echo default +exit 0 +SH + +cat >"$WORKDIR/bin/xdg-open" <>"$WORKDIR/opened" +SH + +chmod +x "$WORKDIR/bin/"* + +receive() { + local expected="$1" + shift + + : >"$WORKDIR/notifications" + PATH="$WORKDIR/bin:$PATH" "$@" "$ROOT/bin/omarchy-tailscale-receive" --once "$downloads" + + for _ in {1..50}; do + (($(wc -l <"$WORKDIR/notifications") >= expected)) && break + sleep 0.1 + done +} + +printf 'png' >"$WORKDIR/outbox/photo.png" +printf 'pdf' >"$WORKDIR/outbox/notes with space.pdf" +receive 2 env + +notifications=$(<"$WORKDIR/notifications") + +[[ -f $downloads/photo.png && -f "$downloads/notes with space.pdf" ]] || + fail "taildrop receive saves incoming files" "$(ls "$downloads")" +pass "taildrop receive saves incoming files" + +grep -qF -- "Received photo.png Saved to $downloads --image $downloads/photo.png" <<<"$notifications" || + fail "taildrop receive previews received images" "$notifications" +pass "taildrop receive previews received images" + +grep -q "^Received notes with space.pdf .* -g " <<<"$notifications" || + fail "taildrop receive announces other files with a glyph" "$notifications" +pass "taildrop receive announces other files with a glyph" + +grep -qxF "$downloads/photo.png" "$WORKDIR/opened" || + fail "taildrop receive opens a clicked file" "$(cat "$WORKDIR/opened" 2>/dev/null)" +pass "taildrop receive opens a clicked file" + +grep -q "unrelated.txt" <<<"$notifications" && + fail "taildrop receive leaves the rest of the downloads directory alone" "$notifications" +pass "taildrop receive leaves the rest of the downloads directory alone" + +# A second delivery of the same name, alongside a download that arrives while +# Taildrop is waiting. +printf 'png' >"$WORKDIR/outbox/photo.png" +receive 1 env DECOY=browser-download.iso + +notifications=$(<"$WORKDIR/notifications") + +[[ -f $downloads/photo-1.png ]] || fail "taildrop receive keeps both files on a name clash" "$(ls "$downloads")" +grep -q "^Received photo-1.png " <<<"$notifications" || + fail "taildrop receive keeps both files on a name clash" "$notifications" +pass "taildrop receive keeps both files on a name clash" + +grep -q "browser-download.iso" <<<"$notifications" && + fail "taildrop receive ignores downloads that arrive while it waits" "$notifications" +pass "taildrop receive ignores downloads that arrive while it waits" + +[[ -z $(ls -A "$downloads/.omarchy-taildrop") ]] || + fail "taildrop receive empties its staging directory" "$(ls -A "$downloads/.omarchy-taildrop")" +pass "taildrop receive empties its staging directory" diff --git a/test/shell.d/tailscale-test.sh b/test/shell.d/tailscale-test.sh index dfb21de2..d46925a0 100644 --- a/test/shell.d/tailscale-test.sh +++ b/test/shell.d/tailscale-test.sh @@ -28,7 +28,9 @@ const status = tailscale.parseStatus(JSON.stringify({ Self: { HostName: 'dhh-fd', DNSName: 'dhh-fd.tail32f559.ts.net.', - TailscaleIPs: ['100.74.97.73'] + TailscaleIPs: ['100.74.97.73'], + UserID: 1001, + CapMap: { 'https://tailscale.com/cap/file-sharing': null } }, Peer: { onlineB: { @@ -38,7 +40,9 @@ const status = tailscale.parseStatus(JSON.stringify({ Online: true, OS: 'linux', ExitNodeOption: true, - ExitNode: true + ExitNode: true, + UserID: 1002, + TaildropTarget: 5 }, offline: { HostName: 'offline', @@ -61,7 +65,9 @@ const status = tailscale.parseStatus(JSON.stringify({ DNSName: 'alpha.tail32f559.ts.net.', TailscaleIPs: ['100.1.1.1', 'fd7a:115c:a1e0::1901:334b'], Online: true, - OS: 'macos' + OS: 'macos', + UserID: 1001, + TaildropTarget: 1 }, mullvadExit: { HostName: 'al-tia-wg-003', @@ -83,6 +89,20 @@ assert(status.peers[1].ExitNodeOption && status.peers[1].ExitNode, 'tailscale pr assertDeepEqual(status.exitNodes.map(peer => peer.HostName), ['zed'], 'tailscale lists only online tailnet exit nodes') assert(tailscale.isMullvadPeer({ HostName: 'al-tia-wg-003', DNSName: 'al-tia-wg-003.mullvad.ts.net.' }), 'tailscale detects Mullvad status peers') +assert(status.fileSharing, 'tailscale reads Taildrop capability from the status capability map') +assertEqual(status.selfUserId, '1001', 'tailscale records the owning user of this machine') +assertDeepEqual(status.peers.map(peer => peer.UserID), ['1001', '1002'], 'tailscale records the owning user of each peer') +assert( + tailscale.hasFileSharing({ Capabilities: ['https://tailscale.com/cap/file-sharing'] }), + 'tailscale reads Taildrop capability from the legacy capability list' +) +assert(!tailscale.hasFileSharing({ CapMap: { funnel: null } }), 'tailscale reports no Taildrop without the capability') +assertDeepEqual(status.peers.map(peer => peer.TaildropTarget), [1, 5], 'tailscale records how Tailscale grades each Taildrop target') +assert(tailscale.isTaildropTarget({ TaildropTarget: 1, UserID: '1001' }, '2002'), 'tailscale trusts an available Taildrop target') +assert(!tailscale.isTaildropTarget({ TaildropTarget: 7, UserID: '1001' }, '1001'), 'tailscale skips peers Tailscale rules out') +assert(tailscale.isTaildropTarget({ UserID: '1001' }, '1001'), 'tailscale falls back to same-owner peers without a grade') +assert(!tailscale.isTaildropTarget({ UserID: '1002' }, '1001'), 'tailscale skips other owners without a grade') + const mullvadNodes = tailscale.parseExitNodeList(` IP HOSTNAME COUNTRY CITY STATUS 100.65.216.13 au-adl-wg-301.mullvad.ts.net Australia Any - From 9b9d4b39eb1db8e8320a5a2cbbc1a4f5e87965ce Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 19:19:33 -0700 Subject: [PATCH 17/19] Never leave updatedb.conf with two definitions of the same setting updatedb refuses to run at all on a config that defines a variable twice ("variable `PRUNEPATHS' was already defined"), so any rewrite that misses an existing line and appends a second one takes the locate index down rather than fixing it. Two shapes updatedb accepts got missed: a trailing comment after the value, and a setting indented by whitespace. Read the existing paths out of the quoted value and write the whole setting back canonically instead of splicing into a line of unknown shape. Quotes are not optional to updatedb ("value in quotes expected after `='"), so a bare value is already a broken config: rewriting it quoted repairs the file as a side effect. The tests now hand every rewritten file to the real parser through `updatedb --config-file`, which is what caught this. Read the Snapper config as root when the running user cannot read it. snapper create-config leaves the config root-only, and a config the user cannot read was passing for one that wants its timeline snapshots kept. Report the snapshots the drain could not delete. omarchy-migrate writes the completion marker whether or not the batches succeeded, so there is no later run to pick up the remainder, whatever the comment claimed. Co-Authored-By: Claude Opus 5 (1M context) --- install/config/locate.sh | 25 +++++++------ migrations/1784809452.sh | 23 +++++++++--- test/shell.d/locate-test.sh | 41 ++++++++++++++++++++-- test/shell.d/snapper-timeline-leak-test.sh | 30 +++++++++++++--- 4 files changed, 97 insertions(+), 22 deletions(-) diff --git a/install/config/locate.sh b/install/config/locate.sh index 529fa1ab..cc95e9b8 100644 --- a/install/config/locate.sh +++ b/install/config/locate.sh @@ -4,24 +4,29 @@ 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 '^PRUNE_BIND_MOUNTS[[:space:]]*=' "$UPDATEDB_CONF_PATH"; then - sed -i -E 's|^PRUNE_BIND_MOUNTS[[:space:]]*=.*|PRUNE_BIND_MOUNTS = "no"|' "$UPDATEDB_CONF_PATH" +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. -# The quotes are optional in updatedb.conf, so read the paths back out of -# whatever quoting the file uses and write the setting in one canonical form. -pruned=$(sed -nE 's|^PRUNEPATHS[[:space:]]*=[[:space:]]*"?([^"]*)"?[[:space:]]*$|\1|p' "$UPDATEDB_CONF_PATH" | tail -n 1) +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 - if [[ -n $pruned ]]; then - sed -i -E "s|^PRUNEPATHS[[:space:]]*=.*|PRUNEPATHS = \"/.snapshots $pruned\"|" "$UPDATEDB_CONF_PATH" - else - printf '%s\n' 'PRUNEPATHS = "/.snapshots"' >>"$UPDATEDB_CONF_PATH" + if [[ " $pruned " != *" /.snapshots "* ]]; then + sed -i -E "s|^[[:space:]]*PRUNEPATHS[[:space:]]*=.*|PRUNEPATHS = \"/.snapshots${pruned:+ $pruned}\"|" "$UPDATEDB_CONF_PATH" fi +else + printf '%s\n' 'PRUNEPATHS = "/.snapshots"' >>"$UPDATEDB_CONF_PATH" fi diff --git a/migrations/1784809452.sh b/migrations/1784809452.sh index e32da36b..d70d7659 100644 --- a/migrations/1784809452.sh +++ b/migrations/1784809452.sh @@ -14,8 +14,14 @@ 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. -grep -qFx 'TIMELINE_CREATE="no"' "$SNAPPER_CONFIG_PATH" || exit 0 +# 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 @@ -28,16 +34,23 @@ echo "Deleting $(wc -w <<<"$leaked") leaked timeline snapshots (disk space is re # 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: whatever survives is picked up by the next run. +# 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[@]}" || true + as_root snapper -c root delete "${batch[@]}" || failed=$((failed + ${#batch[@]})) batch=() fi done if (( ${#batch[@]} > 0 )); then - as_root snapper -c root delete "${batch[@]}" || true + 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 " fi diff --git a/test/shell.d/locate-test.sh b/test/shell.d/locate-test.sh index 77446103..cab54c24 100644 --- a/test/shell.d/locate-test.sh +++ b/test/shell.d/locate-test.sh @@ -18,6 +18,19 @@ PRUNEPATHS = "/afs /media /mnt /net /sfs /tmp /udev /var/cache /var/lib/pacman/l 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" @@ -25,11 +38,13 @@ OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/nul 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 @@ -44,6 +59,7 @@ OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/nul 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" @@ -53,16 +69,34 @@ OMARCHY_UPDATEDB_CONF_PATH="$conf" bash -euo pipefail "$config_script" >/dev/nul 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" +[[ $(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 /tmp"' "$conf" || fail "locate config keeps the paths an unquoted PRUNEPATHS already prunes" -[[ $(grep -c '^PRUNEPATHS' "$conf") -eq 1 ]] || fail "locate config replaces an unquoted PRUNEPATHS instead of adding a second one" +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. @@ -72,6 +106,7 @@ 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) diff --git a/test/shell.d/snapper-timeline-leak-test.sh b/test/shell.d/snapper-timeline-leak-test.sh index a5adb84f..21b15ac7 100644 --- a/test/shell.d/snapper-timeline-leak-test.sh +++ b/test/shell.d/snapper-timeline-leak-test.sh @@ -15,6 +15,7 @@ mkdir -p "$fake_bin" cat >"$fake_bin/sudo" <<'STUB' #!/bin/bash +printf 'sudo %s\n' "$*" >>"$TEST_LOG" exec "$@" STUB chmod +x "$fake_bin/sudo" @@ -72,14 +73,18 @@ echo "failure: dbus timeout" >&2 exit 1 STUB -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 || +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" @@ -102,3 +107,20 @@ OMARCHY_SNAPPER_CONFIG_PATH="$test_tmp/missing" \ [[ ! -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" From b4d6b775c70f54ccd38951a94af3540a9425bbfa Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 19:02:15 -0700 Subject: [PATCH 18/19] Point the Snapper test at paths that still exist Its setup check grepped omarchy-setup-system for config/snapper.sh, which the config phase has run since the install was split into phases. The omarchy-pkgs and omarchy-iso lookups also missed the sibling checkout layout that f375113f taught config-test about, so they failed on machines where config-test passed. Both now take an explicit path override. Co-Authored-By: Claude Opus 5 (1M context) --- test/shell.d/snapper-test.sh | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/test/shell.d/snapper-test.sh b/test/shell.d/snapper-test.sh index 2730543a..68bcee04 100644 --- a/test/shell.d/snapper-test.sh +++ b/test/shell.d/snapper-test.sh @@ -72,7 +72,10 @@ grep -Fx 'systemctl enable --now snapper-cleanup.timer limine-snapper-sync.servi pass "snapshot configure normalizes Snapper policy and services" setup_system="$ROOT/bin/omarchy-setup-system" -grep -F 'config/snapper.sh' "$setup_system" >/dev/null +grep -F 'config/all.sh' "$setup_system" >/dev/null || + fail "system setup runs the config phase" +grep -F 'config/snapper.sh' "$ROOT/install/config/all.sh" >/dev/null || + fail "config phase normalizes Snapper" pass "system setup normalizes Snapper during fresh installs" migration=$(grep -rl 'Normalize Snapper snapshot services' "$ROOT/migrations" | head -n 1 || true) @@ -84,12 +87,18 @@ grep -F 'as_root env OMARCHY_PATH="$OMARCHY_PATH" bash -euo pipefail "$snapper_c ! grep -F 'NUMBER_LIMIT="5"' "$migration" >/dev/null || fail "Snapper service migration does not overwrite working custom retention" pass "Snapper service migration only repairs broken services idempotently" +# Checkouts differ per machine, so allow an explicit pointer at the sibling repo. +# Accepts either the omarchy-pkgs checkout or its pkgbuilds/ directory. find_omarchy_pks_root() { local candidate for candidate in \ + ${OMARCHY_PKGS_PATH:+"$OMARCHY_PKGS_PATH/pkgbuilds" "$OMARCHY_PKGS_PATH"} \ "$ROOT/../omarchy-pkgs/pkgbuilds" \ "$ROOT/../omarchy/omarchy-pkgs/pkgbuilds" \ - "$ROOT/../../omarchy-pkgs/pkgbuilds"; do + "$ROOT/../../omarchy-pkgs/pkgbuilds" \ + "$ROOT/../omacom/omarchy-pkgs/pkgbuilds" \ + "$ROOT/../../omacom/omarchy-pkgs/pkgbuilds" \ + "$HOME/Work/omacom/omarchy-pkgs/pkgbuilds"; do if [[ -d $candidate ]]; then cd "$candidate" && pwd return 0 @@ -111,12 +120,17 @@ grep -F 'cp -a install "$pkgdir/usr/share/omarchy/"' "$omarchy_pkgbuild" >/dev/n grep -F 'cp -a migrations "$pkgdir/usr/share/omarchy/"' "$omarchy_pkgbuild" >/dev/null || fail "omarchy package bundles migrations" pass "omarchy-pkgs packages Snapper template, setup, and migration coverage" +# Same per-machine checkout problem as omarchy-pkgs; OMARCHY_ISO_PATH points at it. find_omarchy_iso_root() { local candidate for candidate in \ + ${OMARCHY_ISO_PATH:+"$OMARCHY_ISO_PATH"} \ "$ROOT/../omarchy-iso" \ "$ROOT/../omarchy/omarchy-iso" \ - "$ROOT/../../omarchy-iso"; do + "$ROOT/../../omarchy-iso" \ + "$ROOT/../omacom/omarchy-iso" \ + "$ROOT/../../omacom/omarchy-iso" \ + "$HOME/Work/omacom/omarchy-iso"; do if [[ -d $candidate ]]; then cd "$candidate" && pwd return 0 From 751165e2012a37aa32c8de3a240706d4a1c16265 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 26 Jul 2026 19:59:37 -0700 Subject: [PATCH 19/19] Don't restart a zram device that exists but is swapped off The resize guard reads the Used column for /dev/zram0 out of /proc/swaps and treats a missing row as an empty device, which is right for a device that doesn't exist yet: the restart is what brings it up against the config daemon-reload just generated. A device that exists and is merely swapped off reads the same, and there the restart resets it first, which returns EBUSY for as long as anything still holds it open. That leaves a bare "Job failed. See 'journalctl -xe' for details." in the migration output and falls through to asking for the reboot that would have resized it anyway. Tell the two apart by whether /sys/block/zram0/disksize is there at all. The test modelled an absent device as a blank disksize file, which no longer stands in for one, so it removes the file instead. Co-Authored-By: Claude Opus 5 (1M context) --- migrations/1785094500.sh | 11 ++++++++++- test/shell.d/zram-resize-test.sh | 19 ++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/migrations/1785094500.sh b/migrations/1785094500.sh index d858ab63..36c05a89 100644 --- a/migrations/1785094500.sh +++ b/migrations/1785094500.sh @@ -30,7 +30,16 @@ if sudo systemctl daemon-reload; then # pressure keeps its old size until the next boot. zram_used=$(awk '$1 == "/dev/zram0" {print $4}' "$swaps") - if [[ ${zram_used:-0} == 0 ]] && sudo systemctl restart dev-zram0.swap; then + # No row at all means the device is not swap right now, and that covers two + # unlike situations. A device that doesn't exist yet is the one worth acting + # on: the restart brings it up against the config daemon-reload just picked + # up. A device that exists but is swapped off is not, because the restart + # resets it first, and reset returns EBUSY for as long as anything still + # holds it open. That leaves a "Job failed" from systemd in the migration + # output and still ends up asking for the reboot, so go straight there. + if [[ -n $zram_used || ! -e $zram_disksize ]] && + [[ ${zram_used:-0} == 0 ]] && + sudo systemctl restart dev-zram0.swap; then exit 0 fi fi diff --git a/test/shell.d/zram-resize-test.sh b/test/shell.d/zram-resize-test.sh index 589c55aa..a71c3800 100644 --- a/test/shell.d/zram-resize-test.sh +++ b/test/shell.d/zram-resize-test.sh @@ -50,7 +50,15 @@ desired_bytes=$((8192 * 1024 * 1024)) run_migration() { local disksize="$1" used="$2" fail_reload="${3:-0}" - printf '%s' "$disksize" >"$TMPDIR/disksize" + # A machine with no zram device has no /sys/block/zram0 at all, so an empty + # size means the file is gone rather than blank; the migration tells those + # two apart now. + if [[ -n $disksize ]]; then + printf '%s' "$disksize" >"$TMPDIR/disksize" + else + rm -f "$TMPDIR/disksize" + fi + printf 'Filename\tType\tSize\tUsed\tPriority\n' >"$TMPDIR/swaps" [[ -n $used ]] && printf '/dev/zram0 partition 8388604 %s 100\n' "$used" >>"$TMPDIR/swaps" @@ -101,6 +109,15 @@ run_migration "" "" did "systemctl restart dev-zram0.swap" || fail "absent device is created" pass "absent device is created" +# A device that exists but is swapped off reads empty too, and there the +# restart resets it, which fails against whatever still holds it open. Nothing +# to gain over the reboot that would have resized it anyway. +run_migration $((4096 * 1024 * 1024)) "" +did "systemctl restart" && fail "swapped-off device is not restarted" +did "omarchy-state set reboot-required" || fail "swapped-off device asks for a reboot" +pass "swapped-off device is not restarted" +pass "swapped-off device asks for a reboot" + # A failed daemon-reload must fall back to asking for a reboot. run_migration $((4096 * 1024 * 1024)) 0 1 did "systemctl restart" && fail "failed reload does not restart"