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) <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-07-26 14:53:29 -07:00
co-authored by Claude Opus 5
parent 171b6374c6
commit 425c3ff84d
16 changed files with 184 additions and 106 deletions
+1 -1
View File
@@ -262,7 +262,7 @@ This copies `$OMARCHY_PATH/config/hypr/hyprlock.conf` to `~/.config/hypr/hyprloc
Read `docs/migrations.md` before creating or changing migrations.
Migrations are per-user and run through `omarchy-migrate` during `omarchy update` or from the migration notification. Put migrations directly under `migrations/<timestamp>.sh`. Pending state is per-user under `~/.local/state/omarchy/migrations/`, so every user gets a chance to run every migration. Migrations run as the user; privileged work should invoke the appropriate helper or privilege prompt, and no-op when another user already applied it.
Migrations are per-user and run through `omarchy-migrate` during `omarchy update` or from the login-time migration notification. Put migrations directly under `migrations/<timestamp>.sh`. Pending state is per-user under `~/.local/state/omarchy/migrations/`, so every user gets a chance to run every migration. Migrations run as the user; privileged work should invoke the appropriate helper or privilege prompt, and no-op when another user already applied it.
To create a new migration, run `omarchy-dev-add-migration --no-edit`.
+1 -38
View File
@@ -53,37 +53,6 @@ log_first_run() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >>"$FIRST_RUN_LOG"
}
notification_server_ready() {
if omarchy-cmd-present gdbus; then
gdbus call --session \
--dest org.freedesktop.Notifications \
--object-path /org/freedesktop/Notifications \
--method org.freedesktop.Notifications.GetServerInformation >/dev/null 2>&1
elif omarchy-cmd-present busctl; then
busctl --user call \
org.freedesktop.Notifications \
/org/freedesktop/Notifications \
org.freedesktop.Notifications \
GetServerInformation >/dev/null 2>&1
else
return 0
fi
}
wait_for_notifications() {
omarchy-cmd-present omarchy-shell || return 0
for _ in {1..100}; do
if omarchy-shell notifications ping >/dev/null 2>&1 && notification_server_ready; then
return 0
fi
sleep 0.1
done
log_first_run "Timed out waiting for notification service; continuing"
return 0
}
run_first_run_step() {
local name="$1"
shift
@@ -98,12 +67,6 @@ run_first_run_step() {
fi
}
wait_for_notifications
run_first_run_step "enable migration notification watcher" \
systemctl --user enable --now omarchy-update-user-notify.path
run_first_run_step "notify about pending migrations" omarchy-migrate-notify
run_first_run_step "install Voxtype post-update hook" \
omarchy-hook-install post-update "$OMARCHY_PATH/install/user/first-run/install-voxtype.hook"
run_first_run_step "install fingerprint setup post-update hook" \
@@ -118,7 +81,7 @@ run_first_run_step "set GTK primary paste" \
run_first_run_step "apply speaker tuning" \
bash "$OMARCHY_PATH/install/user/first-run/audio-tuning.sh"
wait_for_notifications
omarchy-notification-wait || log_first_run "Timed out waiting for notification service; continuing"
run_first_run_step "show welcome notification" \
bash "$OMARCHY_PATH/install/user/first-run/welcome.sh"
# The first-run notification scripts register action callbacks in background
+3 -2
View File
@@ -96,6 +96,7 @@ while IFS=$'\t' read -r name file marker; do
fi
done < <(migration_entries)
# Clear notifications queued while an update was installing migrations. The
# substring matches both the current and legacy notification titles.
# Clear a login-time notification the user left sitting there and then resolved
# by running migrations some other way. The substring matches both the current
# and legacy notification titles.
omarchy-notification-dismiss "Omarchy Migrations" >/dev/null 2>&1 || true
+10 -4
View File
@@ -15,11 +15,17 @@ fi
notify_command=$(printf 'if [[ -n $(omarchy-notification-send -u critical -g  "Pending Omarchy Migrations" %q -a) ]]; then omarchy-launch-floating-terminal-with-presentation omarchy-migrate; fi' "$message")
if omarchy-cmd-present systemd-run; then
unit="omarchy-migrations-notification-$(date +%Y%m%d%H%M%S)"
systemd-run --user --scope --unit="$unit" bash -lc "$notify_command" >/dev/null 2>&1 && exit 0
fi
# This runs from omarchy-migrate-notify.service at graphical-session.target,
# which the session can reach before the shell has claimed
# org.freedesktop.Notifications. Without the wait the toast is sent into the
# void and the user never learns about their pending migrations.
omarchy-notification-wait || true
unit="omarchy-migrations-notification-$(date +%Y%m%d%H%M%S)"
systemd-run --user --scope --unit="$unit" bash -lc "$notify_command" >/dev/null 2>&1 && exit 0
# Reached when there is no user manager to run the scope under, such as a
# non-graphical shell, so fall back to telling the user in the terminal.
print_pending_migrations() {
echo "Omarchy has pending migrations. Run omarchy-migrate in a terminal to apply them:"
while IFS= read -r migration; do
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# omarchy:summary=Wait for the desktop notification server to accept notifications
# omarchy:args=[timeout-seconds]
# omarchy:hidden=true
set -uo pipefail
timeout=${1:-10}
notification_server_ready() {
busctl --user call \
org.freedesktop.Notifications \
/org/freedesktop/Notifications \
org.freedesktop.Notifications \
GetServerInformation >/dev/null 2>&1
}
# The shell has to be up to serve the IPC, and it has to have claimed the
# notification bus name before notify-send has anywhere to deliver.
attempts=$((timeout * 10))
while (( attempts > 0 )); do
if omarchy-shell notifications ping >/dev/null 2>&1 && notification_server_ready; then
exit 0
fi
attempts=$((attempts - 1))
sleep 0.1
done
exit 1
@@ -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
@@ -1,14 +0,0 @@
[Unit]
Description=Watch for Omarchy migrations
[Path]
# Edge-triggered watch only. PathExistsGlob= is level-triggered: it re-fires
# every time the triggered unit deactivates for as long as the glob matches,
# and applied migrations stay on disk forever, so a glob here busy-loops the
# oneshot service. The once-per-login check lives in the service's own
# WantedBy=graphical-session.target instead.
PathModified=/usr/share/omarchy/migrations
Unit=omarchy-update-user-notify.service
[Install]
WantedBy=graphical-session.target
@@ -1,10 +0,0 @@
[Unit]
Description=Notify about pending Omarchy migrations
ConditionPathIsDirectory=/usr/share/omarchy/migrations
[Service]
Type=oneshot
ExecStart=/usr/bin/omarchy-migrate-notify
[Install]
WantedBy=graphical-session.target
+15 -9
View File
@@ -197,13 +197,19 @@ migration. Migrations run as the user; privileged work should invoke the
appropriate helper or privilege prompt. Migrations must be idempotent;
machine-wide repairs should no-op when another user already applied them.
Each graphical user has `omarchy-update-user-notify.path` watching the packaged
migration directory for changes, and `omarchy-update-user-notify.service` is
also started once per login via its own `WantedBy=graphical-session.target`.
Either way the service runs `omarchy-migrate-notify` as that user. The notifier checks
`omarchy-migrate --pending`. If this user has missing migration state, it shows a
notification that opens a terminal for `omarchy-migrate`. The notifier never runs
migrations in the background.
Each graphical user has `omarchy-migrate-notify.service`, started once per login
through `WantedBy=graphical-session.target`. The package also ships
`omarchy-update-user-notify.service` as a symlink onto it, so users enabled
under the old unit name keep working before they reach migration `1785095882`.
It runs `omarchy-migrate-notify` as
that user, which checks `omarchy-migrate --pending`. If this user has missing
migration state, it shows a notification that opens a terminal for
`omarchy-migrate`. The notifier never runs migrations in the background.
Login is the only trigger. Nothing watches the packaged migration directory: a
watcher cannot tell a bypassed `pacman -Syu` from the package transaction inside
a normal `omarchy update`, so it notified about migrations that `omarchy-migrate`
was already applying in the visible update terminal.
`omarchy-migrate` waits for any active pacman transaction to finish, then runs
pending migrations. It does not need `--force`; migrations happen when state
@@ -221,8 +227,8 @@ systemd instance:
Voxtype post-update hook.
- `install/user/first-run/enable-user-units.sh``systemctl --user enable`
the shipped user units (`bt-agent`, `omarchy-sleep-lock`,
`omarchy-recover-internal-monitor`, `omarchy-update-user-notify.path`,
`omarchy-update-user-notify.service`). Done here, not at finalize, because
`omarchy-recover-internal-monitor`, `omarchy-migrate-notify.service`).
Done here, not at finalize, because
the user manager isn't reachable from the ISO chroot; `ConditionPath*`
in the unit files keeps services inert when they don't apply.
- `install/user/first-run/gnome-theme.sh`,
+11 -9
View File
@@ -43,16 +43,9 @@ omarchy-hook post-update
`omarchy-migrate` waits for any active pacman transaction to finish, then runs
all pending migrations for the current user in the visible update terminal.
### During direct pacman updates
### At login
Raw `sudo pacman -Syu` is guarded. Users should normally run:
```bash
omarchy update
```
If a user explicitly bypasses the guard, user sessions watch the packaged
migration directory and run a notifier. The notifier checks:
Every graphical login starts `omarchy-migrate-notify.service`, which checks:
```bash
omarchy-migrate --pending
@@ -67,6 +60,15 @@ omarchy-migrate
The notifier never runs migrations silently in the background.
This is what covers users who did not run the update themselves: someone who
bypassed the pacman guard with `sudo env OMARCHY_ALLOW_DIRECT_PACMAN=1 pacman
-Syu`, and any second user on the machine, whose migration markers are per-user
and therefore still missing after another user updated.
Login is the only trigger on purpose. Watching the packaged migration directory
also fires during a normal `omarchy update`, which prompts for migrations that
`omarchy-migrate` is about to run in the visible update terminal.
### Manually
Users can safely run:
+26 -8
View File
@@ -149,20 +149,37 @@ High-level flow:
sudo pacman -Syu
├─ pre-transaction guard aborts and tells the user to run omarchy update
└─ if explicitly bypassed, upgrades omarchy and related packages
└─ user session notices migration directory changes
├─ omarchy-update-user-notify.path triggers, if enabled
└─ at that user's next login
├─ omarchy-migrate-notify.service starts with graphical-session.target
├─ omarchy-migrate-notify checks omarchy-migrate --pending
├─ if this user has missing migration state, show notification
└─ click opens terminal: omarchy-migrate
```
Login is deliberately the only trigger. A watcher on the packaged migration
directory cannot distinguish a bypassed `pacman -Syu` from the package
transaction inside a normal `omarchy update`, so it fired notifications for
migrations that `omarchy-migrate` was about to apply in the visible update
terminal. The retired unit was `omarchy-update-user-notify.path`.
Fallbacks:
- `omarchy-first-run` enables the user notification path unit.
- `omarchy-first-run` also invokes `omarchy-migrate-notify` on graphical
startup, so users who updated before the path unit existed still get prompted
if they have missing migration state.
- `omarchy-first-run` enables `omarchy-migrate-notify.service`, which also
covers users created after install: their per-user migration markers are
missing, so their first login prompts them to run every shipped migration.
- The package ships `omarchy-update-user-notify.service` as a symlink onto
`omarchy-migrate-notify.service`. Users set up before the rename hold an
absolute `graphical-session.target.wants` symlink to the old path, and the
migration that repoints it only runs for users who run an update — the
opposite of who the notifier is for. The alias can be dropped once installs
have run migration `1785095882`.
- The notifier waits for a live notification server before sending, because
`graphical-session.target` can be reached before the shell claims
`org.freedesktop.Notifications`.
- The notifier is only a prompt. It does not run migrations in the background.
- A session that is already open when another user updates is not re-checked;
it picks the migrations up at its next login, or whenever that user runs
`omarchy-migrate` or `omarchy update`.
- Direct pacman updates do not run `omarchy-hook post-update` unless the user
explicitly runs that hook; without a package-update marker, the only pending
state we can derive is missing per-user migration markers.
@@ -209,7 +226,7 @@ scripts.
| `omarchy-update-system-pkgs` | Runs `sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm` with targeted transition `--overwrite` entries so the ALPM guard allows the transaction and early package-layout conflicts are handled. | **Keep for now.** Small leaf command, clear/testable. |
| `omarchy-migrate` | Public migration command. Waits for pacman, then runs all pending migrations for the current user. Supports `--pending`. | **Keep.** This replaces the discarded `omarchy-update-user-finalize` name and no longer needs `--force`. |
| `omarchy-update-pacman-guard` | ALPM pre-transaction guard that aborts direct `pacman -Syu` style upgrades unless Omarchy set `OMARCHY_UPDATE_PACMAN=1` or the user explicitly set `OMARCHY_ALLOW_DIRECT_PACMAN=1`. | **Keep internal/hidden.** This is what nudges users back to `omarchy update`. |
| `omarchy-migrate-notify` | Internal notification helper for direct pacman updates. Uses `omarchy-migrate --pending` and shows notification only when this user has pending migrations. | **Keep internal/hidden.** Clear name now that the public command is `omarchy-migrate`. |
| `omarchy-migrate-notify` | Internal login-time notification helper. Uses `omarchy-migrate --pending` and shows a notification only when this user has pending migrations. | **Keep internal/hidden.** Clear name now that the public command is `omarchy-migrate`. |
| `omarchy-update-user-notify` | Hidden compatibility wrapper for `omarchy-migrate-notify`. | **Temporary.** Keep only for old callers. |
| `omarchy-update-available` | Update checker for shell widget and post-update refresh. | **Keep.** Could eventually be renamed `omarchy-update-check`, but current name matches widget semantics. |
| `omarchy-update-aur-pkgs` | Updates AUR packages with `yay -Sua` if foreign packages exist and AUR is reachable. | **Question.** Omarchy is package-backed now, but users may still install AUR packages. Keep for now. |
@@ -229,7 +246,8 @@ scripts.
idempotent when they repair machine-wide state.
2. **Migration notification naming**
- The real helper is `omarchy-migrate-notify`.
- The real helper is `omarchy-migrate-notify`, started by
`omarchy-migrate-notify.service`.
- `omarchy-update-user-notify` remains only as a hidden compatibility wrapper.
3. **Update pipeline ownership**
+1 -2
View File
@@ -16,5 +16,4 @@ systemctl --user enable --now \
bt-agent.service \
omarchy-recover-internal-monitor.service \
omarchy-sleep-lock.service \
omarchy-update-user-notify.path \
omarchy-update-user-notify.service
omarchy-migrate-notify.service
+39
View File
@@ -0,0 +1,39 @@
echo "Only check for pending migrations at login, not on every package update"
# omarchy-update-user-notify.path watched /usr/share/omarchy/migrations, but
# pacman writes that directory during every update -- including the blessed
# `omarchy update`, which runs omarchy-migrate a step later. The watcher fired a
# critical notification for migrations that were already being applied in the
# visible update terminal. Retire the watcher and keep only the once-per-login
# check, now named after the command it runs.
wants_dir="$HOME/.config/systemd/user/graphical-session.target.wants"
systemctl --user daemon-reload >/dev/null 2>&1 || true
# The watcher's unit file is already gone, but it stays loaded in a session that
# started before this update, so stop it before it can fire again.
systemctl --user stop omarchy-update-user-notify.path >/dev/null 2>&1 || true
# Enable the replacement before dropping the old enablement, so a failure here
# can never leave a user with no notifier at all. Enable without --now: this
# usually runs from inside `omarchy update`, and starting the notifier here would
# pop a toast for the migrations running right after it -- the exact behavior
# being removed. `systemctl enable` also needs a live user manager, which
# `omarchy update` over SSH does not have, so fall back to writing precisely the
# symlink it would have written rather than silently doing nothing.
if ! systemctl --user enable omarchy-migrate-notify.service >/dev/null 2>&1; then
mkdir -p "$wants_dir"
ln -sfn /usr/lib/systemd/user/omarchy-migrate-notify.service \
"$wants_dir/omarchy-migrate-notify.service"
fi
# Drop the retired enablement by hand instead of through `systemctl disable`.
# The package ships omarchy-update-user-notify.service as a compatibility
# symlink onto the new unit, for users who have not reached this migration yet,
# so disabling that name here would disable the replacement along with it.
rm -f "$wants_dir/omarchy-update-user-notify.path" \
"$wants_dir/omarchy-update-user-notify.service"
systemctl --user reset-failed omarchy-update-user-notify.path >/dev/null 2>&1 || true
systemctl --user daemon-reload >/dev/null 2>&1 || true
+11 -2
View File
@@ -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",
+6
View File
@@ -33,6 +33,12 @@ bash -c "$command"
SH
chmod +x "$stub_bin/systemd-run"
cat >"$stub_bin/omarchy-notification-wait" <<'SH'
#!/bin/bash
exit 0
SH
chmod +x "$stub_bin/omarchy-notification-wait"
cat >"$stub_bin/omarchy-notification-send" <<'SH'
#!/bin/bash
printf '%s\n' "$@" >"$OMARCHY_TEST_NOTIFY_ARGS"
+14 -7
View File
@@ -28,12 +28,19 @@ grep -F 'ExecStart=/usr/bin/omarchy-system-sleep-monitor' "$upgrade_to_quattro"
grep -F 'reset-failed omarchy-sleep-lock.service' "$upgrade_to_quattro" >/dev/null
pass "Omarchy 4 upgrade repairs the legacy sleep lock unit path"
notify_path="$ROOT/default/systemd/user/omarchy-update-user-notify.path"
! grep -q 'PathExistsGlob' "$notify_path"
grep -Fx 'PathModified=/usr/share/omarchy/migrations' "$notify_path" >/dev/null
pass "migration watcher is edge-triggered so applied migrations on disk cannot re-trigger it"
[[ -e $ROOT/default/systemd/user/omarchy-update-user-notify.path ]] &&
fail "the retired migration watcher is back; pacman writing the migration directory during omarchy update would notify about migrations that update is already applying"
grep -rlE '^(Path[A-Za-z]+|DirectoryNotEmpty)=.*/usr/share/omarchy/migrations' "$ROOT/default/systemd/user" >/dev/null 2>&1 &&
fail "a user unit watches the migration directory again; the notifier must stay login-only"
pass "no unit watches the migration directory, so package updates cannot trigger the notifier"
notify_service="$ROOT/default/systemd/user/omarchy-update-user-notify.service"
! grep -q 'StartLimit' "$notify_service"
notify_service="$ROOT/default/systemd/user/omarchy-migrate-notify.service"
grep -Fx 'ExecStart=/usr/bin/omarchy-migrate-notify' "$notify_service" >/dev/null
grep -Fx 'WantedBy=graphical-session.target' "$notify_service" >/dev/null
pass "migration notifier keeps its start-rate limit and still runs once per login"
pass "migration notifier only checks once per login"
grep -F 'omarchy-migrate-notify.service' "$first_run_units" >/dev/null ||
fail "first-run does not enable the login migration notifier"
grep -F 'omarchy-update-user-notify' "$first_run_units" >/dev/null &&
fail "first-run still enables the retired notifier units"
pass "first-run enables the login-only migration notifier"