diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 8952768d1..135f96765 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -283,6 +283,9 @@ jobs: - name: New bar widgets load after Update VM, also after an unlock (Omarchy's lock status, not isLocked) run: src/tests/restart-shell.sh + - name: Update VM on a locked VM writes no bar widget and asks no rescan until the unlock + run: src/tests/plugin-lock.sh + - name: omacvm check warns when Omarchy does not lock again after an unlock run: src/tests/lock-stuck-check.sh @@ -783,6 +786,9 @@ jobs: - name: Omarchy's shell colours on Qt 6.12 on Linux (GNU tools, the live shell's log by its pid) run: src/tests/shell-colors.sh + - name: Bar widgets under Omarchy's lock on Linux (GNU tools, flock, as in the VM) + run: src/tests/plugin-lock.sh + - name: OmacVM's edits of Omarchy's shell on Linux (GNU tools, as in the VM) run: src/tests/omarchy-shell.sh diff --git a/AGENTS.md b/AGENTS.md index f2bc7240a..36914d1c9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -246,7 +246,7 @@ GitHub. | `src/lib/mac.sh` | Mac helpers: `gssh`, Parallels (`vm_ip` by DHCP lease, `vm_state`, `vm_start`), UTM (`vm_type`, `utm_ip`, `utm_state`, `utm_start`, `utm_wait_stopped`) and Fusion (`fusion_list`: Fusion's `vmInventory`, running VMs and `$FUSION_DIR`, `fusion_ip` from `vmnet-dhcpd-vmnet8.leases`, `fusion_state`, `fusion_start`, `fusion_host`) | | `src/guest/omacvm-omanotch.service` | The omanotch feature: one-shot user unit that runs `/usr/local/share/omacvm/omanotch/guest/install.sh` in the first desktop session (it needs Hyprland running), skipped once `~/.local/bin/notchcast` exists. `src/guest/install.sh` removes notchcast when that copy changed (stamp `~/.local/state/omacvm/omanotch`), so it installs again | | `src/omanotch/` | Omanotch (`git subtree`, history kept): `mac/` (Omanotch.app, Swift; `mac/test.sh` = offline tests), `guest/` (`notchcast`, the bar, background and display panel patches, `notchbar.lua`; `guest/tests` = offline tests). The patches know omarchy-mac's shell and upstream Omarchy's newer one (October 2026: notch floor, a bar thickness per screen, BackgroundMedia, `Commons.Color`): where an anchor differs, each form is listed (`replace_variant`); Omarchy's own files of both in `src/tests/omarchy-shell` (also `src/tests/notch-wallpaper.sh`, `src/tests/omarchy-shell.sh`). Toasts under the bar (parked: only the gap; OmacVM.app's FullPanel: the strip's height + the gap): the bar's `barSizeFor` (newer Omarchy asks it), `guest/omanotch-notifications.sh` on an older notification service. The bar and background clones follow Omarchy's updates like Omarchy's own plugins (`guest/copies.py`, installed to `~/.local/lib/omanotch`, run by `install.sh`, the user unit `omanotch-copies.service` at each login and from OmacVM's pacman hook `zz-omacvm-omanotch-copies.hook` after an update): Omarchy's new plugin with the patch again, merged three ways (`git merge-file`) with the clone against what was written last (`~/.local/state/omanotch/copies//written`), shell lines of shell-colors.py not counted; overlap or a patch that does not fit: the clone stays as it is (check WARN `bar copy`); a clone without a record (3.1.11 and older) is made new once, kept first as `~/.config/omarchy/plugins/.$USER.bar.before-3.1.13` when it differed (WARN with the path for 14 days or until deleted; shell-colors.py skips dot folders); the shell restarts and must load it (no errors on its path, the bar's notchbar IPC) or every changed file goes back (`guest/tests/test_copies.py`). Its own README. Work on it here; github.com/gillesgoetsch/omanotch is archived and points here | -| `src/lib/install-plugin.sh`, `src/lib/omacvm-plugins` | Omarchy shell plugin install; queues until the shell runs (first login); restarts the shell once when a plugin's files changed | +| `src/lib/install-plugin.sh`, `src/lib/omacvm-plugins` | Omarchy shell plugin install; validates the copy first, writes nothing when the folder has it already, queues until the shell runs (first login); restarts the shell once when a plugin's files changed. While the session is locked (`src/guest/session-lock.sh`) the copy waits in `~/.local/state/omacvm/plugins-staged/` and `src/guest/restart-shell.sh`'s job after the unlock puts it in place (section 8). Tests: `src/tests/plugin-lock.sh`, `src/tests/restart-shell.sh` | | `src/lib/sign.sh` | Signs Mac apps with `designated => identifier ""`, so TCC grants survive rebuilds | | `src/control/` | Feature `control-centre` (docs/adr/0030-0032): `omacvm` in the VM, a Textual TUI (`omacvm_cc/tui.py`; `state.py` = the pure status rules, `controller.py` = data, `bridge.py` = the client with the Bridge's proof, `report.py` + `collect.py` = report a problem with redaction and a gate, also run by the Mac's `omacvm report` with macOS's python3 3.9, or OmacVM.app's 3.13 on a Mac without the Command Line Tools: keep it 3.9-compatible). `omacvm status [--json]`, `omacvm report`, `omacvm notify` (user timer: one notice per new version unless update checks are off), `omacvm extra-brightness [on\|off] [--json]` (OmacVM.app's Extra brightness over the control port, answered by the app itself: `/omacvm/app/extra-brightness`, `ExtraBrightnessGuest` in `OmacVMWindow/ExtraBrightness.swift`, ADR 0031; only while that VM's window is in front). Guest install (`guest/install.sh`): Textual from OmacVM's own wheels (`vendor/`, pinned with `SHA256SUMS`, unpacked by `omacvm_cc/vendor.py` into the user's `~/.cache/omacvm/python/`; no pacman: a VM older than the mirrors gets every file as a 404), `/usr/local/bin/omacvm`, root socket `omacvm-check.socket` (runs `guest/check.sh --tsv` for the desktop user, nothing read from the caller), desktop entry, a marked row in `~/.config/omarchy/extensions/omarchy-menu.jsonc`, bar plugin `plugins/omacvm.control`. Tests: `tests/` (pytest + Textual Pilot against `tests/fakes.py`), `tests/vm_e2e.py` drives the real TUI in a test VM | | `src/release/` | Update manifests (ADR 0032): `parts.tsv` (files → parts, first match wins, `core` last), `manifest.py digests|build|parts`, `sign.swift keygen|sign|verify` (Ed25519), `release-key.sh sign|team|teams|spare` (signs with the Keychain key and checks it), `keys.py` (verify on the command line: plain-python Ed25519, either key, kept spares). `omacvm apply` writes the digests it installed to the VM's `/etc/omacvm/installed.json`. Release keys: `src/lib/release-key.pub` (main) and `release-key-spare.pub`, either one signs; documents carry `kind` and `devid_teams`; who holds the private halves and how to rotate: `docs/release-keys.md`. A release: `release.sh` (every step, `--dry-run`, rollback; `docs/releasing.md`). Tests use `OMACVM_FEED_URL` + `OMACVM_FEED_KEY` (Bridge), `OMACVM_APPCAST_KEY` (app test builds), `OMACVM_RELEASE_TEST_KEYS` (command line), all throwaway keys | @@ -662,6 +662,7 @@ The less obvious ones, with causes and where the fix lives, are in | A command replacement in `/usr/local/bin` is ignored by the bar | the Omarchy shell runs with `/usr/share/omarchy/bin` (symlinks to /usr/bin) first on PATH; Hyprland does not | call `/usr/local/bin/...` by full path from QML (see `omacvm.wifiqr`) | | `Target not found` / "handler will not be used" for a widget's IPC | a clone kept the stock widget's IPC target | give clones their own target (`omacvm.wifi`) | | Updated widget does not change | a running shell keeps loaded plugins | `src/lib/install-plugin.sh` flags changes; `src/guest/install.sh` restarts the shell (`guest/restart-shell.sh`; locked: right after the unlock) | +| Omarchy's lock screen without a password field after `omacvm update`/`apply` on a locked VM (3.2.12 and older); journal: "Session lock object was destroyed without unlocking", "lock-stranded: recovering", "lock-pending: screen-stabilizing" | omarchy-mac 4.0.3's shell reloads every plugin, its lock screen too, on a write under `~/.config/omarchy/plugins` or a `rescanPlugins` call; the new lock screen never gets the lock Hyprland still holds | shut the VM down and start it again; from 3.2.13 nothing is written or rescanned under the lock (section 8; docs/troubleshooting.md, 46) | | Disabling an old clone brings the stock widget back next to the new one | `clonedFrom` hand-back | rename ids in `shell.json` instead, or disable the stock widget | | SSIDs null, `location_authorized: false` | Location Services not granted (new bundle id or reset) | Privacy & Security › Location Services | | Media keys still show the macOS popup | Accessibility missing (`permissions:` line, `omacvm check`), no VM in front (Parallels/UTM/Fusion: not full screen), or capture switched off in the bridge menu | `media keys:` / `media key ...: to macOS:` lines in the bridge log | @@ -766,6 +767,26 @@ The less obvious ones, with causes and where the fix lives, are in `copies.py` do. Root's `sudo -u` in an apply has no locale: there `qs ipc` prints Qt's locale warning on stdout before every answer (Omarchy's own scripts misread it too), so give Omarchy's commands `LC_ALL=C.UTF-8`. +- Do not write under `~/.config/omarchy/plugins` or call `omarchy-shell + shell rescanPlugins` while the session is locked or the shell does not + answer (`src/guest/session-lock.sh`: busy counts as locked). The shell of + omarchy-mac 4.0.3 reloads every plugin on either (an inotify watch on that + folder; hidden entries skipped), its lock screen too, and the new one has + no password field until a VM restart (3.2.11, 3.2.12). Upstream Omarchy + keeps such services over the reload (omacom/omarchy#9485); omarchy-mac + 4.0.3 lacks it. Stage the copy outside the folder and let + `guest/restart-shell.sh`'s job put it in place after the unlock, before its + one restart (`lib/install-plugin.sh`, `lib/omacvm-plugins`); `omarchy plugin + validate`, `enable` and `disable` are safe (they read the folder or change + shell.json only). Feature offs under the lock: the control centre goes out + of the bar and its folder stays until the next apply; Omanotch's own + uninstall waits for the unlock (`guest/off.sh`, `guest/restart-shell.sh`); + Omanotch's `display-panel.py` asks Omarchy's lock status as `copies.py` + does. Do not try to recover with a shell restart instead: + `omarchy-restart-shell` refuses while the lock is requested, and a restart + under the lock is what #612 removed. `src/tests/plugin-lock.sh` fails for a + new `rescanPlugins` caller that does not ask first. Not Omarchy's #738 (no + lock after an unlock): that one stays Omarchy's. - Do not leave files in the desktop user's runtime folder (`/run/user/UID`, `XDG_RUNTIME_DIR`): it is a tmpfs of 10 % of the VM's memory, and when it is full, Hyprland and apps die of SIGBUS on their shared-memory buffers there diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fb7c9dd6..63cd12004 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,30 @@ What's new in each OmacVM release. The release notes on GitHub say the same in more words. +## 3.2.13 (unreleased) + +- **Updating a running VM whose screen is locked no longer leaves Omarchy's lock screen without + its password field (#617).** `omacvm update` and `omacvm apply` wrote OmacVM's bar widgets again and + asked Omarchy's shell to rescan them. The shell of omarchy-mac 4.0.3 then reloads all its + plugins, its own lock screen too, and under the lock the new lock screen had no password field + until the VM was shut down and started again (3.2.11 and 3.2.12). Now nothing is written there + and nothing is rescanned while the session is locked: changed widgets wait beside it, and right + after the unlock they go in place and the shell restarts once. A widget that did not change is + no longer written again at every update, and two updates under one lock end with the newest + widgets and one restart. Upstream Omarchy fixed the reload itself (omacom/omarchy#9485); + omarchy-mac does not have it yet. Needs only the update to 3.2.13 (`omacvm update`, or the + app's update and Update VM): that update already waits for the unlock. + +- **Switching the control centre or Omanotch off while the VM is locked no longer leaves the lock + screen without its password field either (#618).** The control centre leaves the bar at once + and its folder goes at the next update; Omanotch stops at once and removes its copies of + Omarchy's plugins right after the unlock (with 3.2.12 the lock screen stayed without its + password field until the VM was shut down and started again). Omanotch's display panel (UTM, + VMware Fusion, Parallels) is no longer rebuilt under the lock after an Omarchy update; the next + start of Omanotch does it. The update to 3.2.13 installs Omanotch's VM side again (its files + changed), also on a locked VM, and the lock screen keeps working there. Needs the update to + 3.2.13. + ## 3.2.12 In an OmacVM.app VM, memory the VM gives back now leaves the Mac, and the VM gives back its diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index fe61fb9b5..c227dc00b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -171,6 +171,11 @@ reviews, measuring pitfalls, how the VM apps work inside) are in redraws once). OmacVM VMs have the idle lock on by default, so an idle VM stays open while it is stuck. `omacvm check` warns while it is stuck ("Omarchy's lock"). +- **The lock screen has no password field after an update of a locked VM** + (`omacvm update` or `omacvm apply` on a running VM whose screen was + locked, 3.2.12 and older): typing the password does nothing. Shut the VM + down and start it again. From 3.2.13 such an update waits for the unlock + (46). - **Touch ID asks for the password instead**: the line above the password prompt says why ("Touch ID not available (VM not in front), use your password", "the Mac is still starting: try again in a moment", ...). @@ -1327,3 +1332,37 @@ Findings 7, 11, 12, 13, 16 and 17 are notes for developers now: `src/app/guest/omacvm-desktop-recover` (the note), `src/cmd/check.sh`; tests `app/runtime/Tests/vcpu/test-vcpu-stall.sh`, `src/tests/vm-stalls.sh`, `swift run desktop-tests`; ADR 0060. + +## 46. All routes: the lock screen has no password field after an update of a locked VM + +- **Symptom:** `omacvm update` or `omacvm apply` ran while the VM's Omarchy + session was locked (3.2.12 and older; also switching Omanotch off). The + lock screen comes back without its password field, and typing the password + does nothing. Omarchy's shell log (`journalctl --user -t omarchy-shell` in + the VM) has "Session lock object was destroyed without unlocking", then + "lock-stranded: recovering" and "lock-pending: screen-stabilizing". +- **Cause:** the shell of omarchy-mac 4.0.3 reloads all its plugins, its own + lock screen too, whenever a file under `~/.config/omarchy/plugins` changes + or it is asked to rescan them. Every update wrote OmacVM's bar widgets + there again and asked for a rescan. The new lock screen never gets the + lock Hyprland still holds, so it waits without a password field. Upstream + Omarchy keeps its lock screen over such a reload (omacom/omarchy#9485); + omarchy-mac 4.0.3 does not have that yet. This is not Omarchy's other lock + bug above (omacom/omarchy-mac#738), which only stops the next lock. +- **Fix:** shut the VM down and start it again (`omarchy-restart-shell` + refuses while the lock is asked for). From 3.2.13 an update of a locked VM + writes nothing there and asks for no rescan: changed widgets wait in + `~/.local/state/omacvm/plugins-staged`, and right after the unlock they go + in place and the shell restarts once. A widget that did not change is not + written again at all. `omacvm check` says "right after the next unlock" for + a widget that waits. Switching the control centre or Omanotch off while + the VM is locked does the same: the control centre leaves the bar at once + and its folder goes at the next update; Omanotch stops at once and removes + its copies of Omarchy's plugins right after the unlock. +- **Where:** `src/guest/session-lock.sh` (the one lock check), + `src/lib/install-plugin.sh`, `src/lib/omacvm-plugins`, + `src/guest/restart-shell.sh`, `src/app/guest/omacvm-displays` (the display + widget), `src/omanotch/guest/monitor/display-panel.py` (Omanotch's display + panel), `src/guest/off.sh` and `src/control/guest/install.sh` (the offs); + tests `src/tests/plugin-lock.sh`, `src/tests/restart-shell.sh`, + `src/tests/features-off.sh`, `src/omanotch/guest/tests/test_display_panel.py`. diff --git a/src/app/guest/omacvm-displays b/src/app/guest/omacvm-displays index 94bbd4bc2..2786d9e85 100755 --- a/src/app/guest/omacvm-displays +++ b/src/app/guest/omacvm-displays @@ -125,6 +125,7 @@ SCALE_WAIT = 4.0 WIDGET = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "omarchy/plugins/omacvm.monitor" WIDGET_BUILD = Path("/usr/local/share/omacvm/app/guest/monitor-widget/build.py") OMARCHY_PANEL = Path("/usr/share/omarchy/shell/plugins/panels/monitor/Panel.qml") +SESSION_LOCK = Path("/usr/local/share/omacvm/guest/session-lock.sh") def log(message: str) -> None: @@ -588,10 +589,25 @@ def run_sync() -> None: log(f"display sync failed: {error}") +def session_state() -> str: + """The desktop session: down, busy, locked or open (guest/session-lock.sh; + open without it).""" + try: + out = subprocess.run(["bash", str(SESSION_LOCK)], capture_output=True, text=True, + timeout=15, check=False).stdout.strip() + except (OSError, subprocess.SubprocessError): + return "open" + return out if out in ("down", "busy", "locked", "open") else "open" + + def refresh_widget() -> None: """Rebuild omacvm.monitor when an Omarchy update changed its display panel. The installer may be rewriting the widget right now: a file gone midway - is not an error (it used to stop the agent).""" + is not an error (it used to stop the agent). Not while the session is + locked: a write in the plugins folder or a rescan reloads Omarchy's lock + screen without its password field (omarchy-mac 4.0.3); Update VM stages + its own copy then, else the agent's next start does it. A shell that is + starting gets 20 s to answer.""" stamp = WIDGET / ".source-sha256" import hashlib try: @@ -603,6 +619,15 @@ def refresh_widget() -> None: except OSError as error: log(f"display widget not checked: {error}") return + state = session_state() + for _ in range(20): + if state != "busy": + break + time.sleep(1) + state = session_state() + if state in ("locked", "busy"): + log("display widget not rebuilt while the session is locked (Update VM or the next start does it)") + return try: subprocess.run([sys.executable, str(WIDGET_BUILD), str(WIDGET)], timeout=30, check=True) subprocess.run(["omarchy-shell", "-q", "shell", "rescanPlugins"], timeout=30, check=False) diff --git a/src/app/guest/tests/test_omacvm_displays.py b/src/app/guest/tests/test_omacvm_displays.py index c71253491..be83ac559 100644 --- a/src/app/guest/tests/test_omacvm_displays.py +++ b/src/app/guest/tests/test_omacvm_displays.py @@ -940,6 +940,52 @@ def test_installer_rewriting_the_widget_does_not_stop_the_agent(self): od.refresh_widget() run.assert_not_called() + @staticmethod + def stale(tmp): + """A widget built from an older Omarchy panel: (widget, build, panel).""" + widget = tmp / "omacvm.monitor" + widget.mkdir() + (widget / ".source-sha256").write_text("old\n") + build = tmp / "build.py" + build.write_text("") + panel = tmp / "Panel.qml" + panel.write_text("x") + return widget, build, panel + + def refresh(self, states): + """refresh_widget with the session in these states, one per look: the commands run.""" + with tempfile.TemporaryDirectory() as tmp: + widget, build, panel = self.stale(pathlib.Path(tmp)) + with mock.patch.object(od, "WIDGET", widget), mock.patch.object(od, "WIDGET_BUILD", build), \ + mock.patch.object(od, "OMARCHY_PANEL", panel), \ + mock.patch.object(od, "session_state", side_effect=states), \ + mock.patch.object(od.time, "sleep"), \ + mock.patch.object(od.subprocess, "run") as run, mock.patch.object(od, "log"): + od.refresh_widget() + return [c.args[0] for c in run.call_args_list] + + def test_not_while_the_session_is_locked(self): + # A write in the plugins folder or a rescan would reload Omarchy's + # lock screen without its password field (omarchy-mac 4.0.3). + self.assertEqual(self.refresh(["locked"]), []) + self.assertEqual(self.refresh(["busy"] * 21), []) # never answered in 20 s + + def test_open_no_shell_or_a_shell_that_answers_in_time(self): + for states in (["open"], ["down"], ["busy", "busy", "open"]): + ran = self.refresh(states) + self.assertEqual(len(ran), 2, states) + self.assertEqual(ran[1], ["omarchy-shell", "-q", "shell", "rescanPlugins"]) + + def test_session_state_reads_session_lock_sh(self): + with tempfile.TemporaryDirectory() as tmp: + script = pathlib.Path(tmp) / "session-lock.sh" + with mock.patch.object(od, "SESSION_LOCK", script): + self.assertEqual(od.session_state(), "open") # not there: as before + script.write_text("echo locked\n") + self.assertEqual(od.session_state(), "locked") + script.write_text("echo something else\n") + self.assertEqual(od.session_state(), "open") + class ConfigWatchTest(unittest.TestCase): def test_wakes_on_a_change_or_falls_back(self): diff --git a/src/control/guest/install.sh b/src/control/guest/install.sh index 58fc7d866..bb0b2f84c 100755 --- a/src/control/guest/install.sh +++ b/src/control/guest/install.sh @@ -90,6 +90,32 @@ window_rule() { # 3.0.6 it came from python-textual, which a VM whose package list is older # than the mirrors cannot get (every download a 404), and the control centre # stayed plain text. Not fatal: omacvm unpacks it itself at its next start. +# The bar item off: out of the bar, the folder gone, nothing queued. Under +# Omarchy's lock the folder stays (a write in ~/.config/omarchy/plugins would +# bring the lock screen back without its password field): out of the bar it +# does nothing, and the next apply removes it (guest/install.sh runs this +# while it is there). +bar_item_off() { + local P=$H/.config/omarchy/plugins/omacvm.control C=$H/.config/omarchy/shell.json tmp st + local Q=$H/.local/state/omacvm/pending-plugins + # Neither a copy staged under the lock (lib/install-plugin.sh) nor a queued enable comes. + home_run rm -rf "$H/.local/state/omacvm/plugins-staged/omacvm.control" < /dev/null + if home_grep "$Q" -qxF omacvm.control; then { grep -vxF omacvm.control < <(home_cat "$Q") || true; } | home_put "$Q"; fi + [[ -d $P ]] || return 0 + # Without a running shell, out of its bar settings by hand (it reads them when it starts). + if ! as_user omarchy plugin disable omacvm.control >/dev/null 2>&1 && [[ -f $C ]]; then + tmp=$(mktemp) + if jq '(.bar.layout[]?) |= map(select(.id != "omacvm.control")) | (.plugins // empty) |= map(select(.id != "omacvm.control"))' \ + < <(home_cat "$C") > "$tmp"; then + home_write "$C" < "$tmp" + fi + rm -f "$tmp" + fi + st=$(as_user bash ../../guest/session-lock.sh 2>/dev/null) || st=down + if [[ $st == locked || $st == busy ]]; then return 0; fi + home_run rm -rf "$P" < /dev/null +} + textual() { local out why try command -v python3 >/dev/null || ../../guest/pkg-add python || true @@ -143,19 +169,6 @@ else home_run rm -f "$H/.config/systemd/user/timers.target.wants/omacvm-notify.timer" menu_line off window_rule off - if [[ -d $H/.config/omarchy/plugins/omacvm.control ]]; then - # Without a running shell, out of its bar settings by hand (it reads them when it starts). - C=$H/.config/omarchy/shell.json - if ! as_user omarchy plugin disable omacvm.control >/dev/null 2>&1 && [[ -f $C ]]; then - tmp=$(mktemp) - if jq '(.bar.layout[]?) |= map(select(.id != "omacvm.control")) | (.plugins // empty) |= map(select(.id != "omacvm.control"))' \ - < <(home_cat "$C") > "$tmp"; then - home_write "$C" < "$tmp" - fi - rm -f "$tmp" - fi - home_run rm -rf "$H/.config/omarchy/plugins/omacvm.control" - home_run sed -i '/^omacvm\.control$/d' "$H/.local/state/omacvm/pending-plugins" 2>/dev/null || true - fi + bar_item_off home_run rm -rf "$H/.cache/omacvm" fi diff --git a/src/guest/check.sh b/src/guest/check.sh index 71c3652b2..c31d50048 100755 --- a/src/guest/check.sh +++ b/src/guest/check.sh @@ -40,6 +40,11 @@ as_user() { bash -c 'source /usr/share/omarchy/default/bash/env-bootstrap 2>/dev/null; exec "$@"' _ "$@" } user_active() { systemctl --user -M "$U@" is-active "$1" >/dev/null 2>&1; } +# When a queued bar widget comes: after the unlock while a job waits for it +# (guest/restart-shell.sh), else at the next login. +queued_when() { + if user_active omacvm-restart-shell.service; then echo "right after the next unlock"; else echo "log out and in"; fi +} connected_to() { ss -Htn state established "dst $1:$2" | grep -q .; } ev_device() { grep -q "^N: Name=\"$1\"" /proc/bus/input/devices; } # missing_pkgs PKG...: why a feature is not set up when pacman could not @@ -298,7 +303,7 @@ if [[ $BRIDGE == on ]]; then else bad "Bluetooth" "no answer from the Bridge"; fi for w in omacvm.bluetooth omacvm.wifi omacvm.audio omacvm.nightshift; do if [[ " $layout " == *" $w "* ]]; then ok "bar: $w" "in the bar" - elif [[ -s $H/.local/state/omacvm/pending-plugins ]]; then bad "bar: $w" "queued, not enabled yet (log out and in)" + elif [[ -s $H/.local/state/omacvm/pending-plugins ]]; then bad "bar: $w" "queued, not enabled yet ($(queued_when))" else bad "bar: $w" "not in the bar"; fi done for w in omarchy.bluetooth omarchy.network omarchy.audio; do @@ -1080,7 +1085,7 @@ if [[ $CONTROL == on ]]; then else bad "Omarchy menu" "no OmacVM row in ~/.config/omarchy/extensions/omarchy-menu.jsonc (omacvm apply)"; fi layout=$(jq -r '[.bar.layout[]?[]?.id] | join(" ")' "$H/.config/omarchy/shell.json" 2>/dev/null) if [[ " $layout " == *" omacvm.control "* ]]; then ok "bar item" "omacvm.control" - elif grep -qx omacvm.control "$H/.local/state/omacvm/pending-plugins" 2>/dev/null; then bad "bar item" "queued, not enabled yet (log out and in)" + elif grep -qx omacvm.control "$H/.local/state/omacvm/pending-plugins" 2>/dev/null; then bad "bar item" "queued, not enabled yet ($(queued_when))" else skip "bar item" "omacvm.control is not in the bar (Omarchy's bar settings add it back)"; fi else skip "control centre" "off (on the Mac: omacvm enable control-centre)"; fi diff --git a/src/guest/install.sh b/src/guest/install.sh index ba1478129..3b05bb940 100755 --- a/src/guest/install.sh +++ b/src/guest/install.sh @@ -101,9 +101,19 @@ in_session() { } # Updated bar widgets load in a new shell: restarted once at the end. restart_shell_later() { home_mkdir "$H/.local/state/omacvm"; home_put "$H/.local/state/omacvm/restart-shell" < /dev/null; } +# A script of OmacVM's as the desktop user (its user units, Omarchy's shell). +user_script() { sudo -u "$U" env XDG_RUNTIME_DIR="/run/user/$(id -u "$U")" bash "$@"; } +# The desktop session: down, busy, locked or open (guest/session-lock.sh). +session_state() { user_script "$R/guest/session-lock.sh" 2>/dev/null || true; } +# Locked, or the shell does not answer: nothing written in the plugins folder (off.sh). +session_locked() { local s; s=$(session_state); [[ $s == locked || $s == busy ]]; } # What off means for the features with a part in the VM that talks to the Mac. ROOT="" source "$R/guest/off.sh" +# A job an earlier apply left to run after the unlock never works beside this +# one: it stops (guest/restart-shell.sh --retire), and the end of this run +# starts one again with what both owe. +user_script "$R/guest/restart-shell.sh" --retire 2>/dev/null || true # Earlier choices, then this run's. ENV=/etc/omacvm/env @@ -216,7 +226,21 @@ Description = OmacVM: Omarchy's shell colours on Qt 6.12 (#376) When = PostTransaction Exec = /usr/local/lib/omacvm/shell-colors.py HOOK - r=$(/usr/local/lib/omacvm/shell-colors.py --user "$U" 2>&1) || r="not all fixed: $r" + st=$(session_state) + if [[ $st == locked || $st == busy ]]; then + # Under the lock only Omarchy's own files (the shell does not watch + # them): a write in the user's plugin folder would reload the lock + # screen without its password field. Those clones get it right after the + # unlock (guest/restart-shell.sh), and the shell restarts then. + r=$(/usr/local/lib/omacvm/shell-colors.py /usr/share/omarchy/shell 2>&1) || r="not all fixed: $r" + s=$(home_run /usr/local/lib/omacvm/shell-colors.py --status "$H/.config/omarchy/plugins" 2>/dev/null < /dev/null) || s="" + case $s in + ""|*"to patch 0, to restore 0") ;; + *) log "Omarchy shell colours: the plugin clones right after the next unlock"; restart_shell_later ;; + esac + else + r=$(/usr/local/lib/omacvm/shell-colors.py --user "$U" 2>&1) || r="not all fixed: $r" + fi case $r in nothing*) ;; patched*|restored*) log "Omarchy shell colours: $r"; restart_shell_later ;; @@ -514,7 +538,8 @@ fi if want control-centre; then if [[ ${F[control-centre]} == on ]]; then log "control centre (omacvm)" elif [[ -e /usr/local/bin/omacvm || -f /etc/systemd/system/omacvm-check.socket ]]; then log "control centre: off"; fi - if [[ ${F[control-centre]} == on || -e /usr/local/bin/omacvm || -f /etc/systemd/system/omacvm-check.socket ]]; then + if [[ ${F[control-centre]} == on || -e /usr/local/bin/omacvm || -f /etc/systemd/system/omacvm-check.socket || + -d $H/.config/omarchy/plugins/omacvm.control || -d $H/.local/state/omacvm/plugins-staged/omacvm.control ]]; then "$R/control/guest/install.sh" "$U" "${F[control-centre]}" "$TYPE" || not_set_up control-centre "control centre" fi fi @@ -647,13 +672,15 @@ if want thp-kernel && [[ ${F[thp-kernel]} != on ]] && pacman -Q linux-aarch64-th pacman -Rn --noconfirm $(pacman -Qq linux-aarch64-thp linux-aarch64-thp-headers 2>/dev/null) >/dev/null fi fi -# Updated bar widgets only load in a new shell: restart it once if any changed, -# never while the session is locked (guest/restart-shell.sh: then right after -# the unlock). +# Updated bar widgets only load in a new shell: guest/restart-shell.sh puts +# what this run staged in place and restarts the shell once if any it had +# loaded changed, never while the session is locked (then right after the +# unlock). No shell: the next one loads what is in place. RS=$H/.local/state/omacvm/restart-shell -if [[ -f $RS ]]; then - home_run rm -f "$RS" - sudo -u "$U" env XDG_RUNTIME_DIR="/run/user/$(id -u "$U")" bash "$R/guest/restart-shell.sh" || true +if pgrep -u "$U" -x quickshell >/dev/null 2>&1; then + user_script "$R/guest/restart-shell.sh" || true +else + home_run rm -f "$RS" < /dev/null fi # The initramfs and GRUB's menu: rebuilt only when what goes into them changed # (several seconds on every apply and update otherwise). Kernel and hook diff --git a/src/guest/off.sh b/src/guest/off.sh index b73d61b72..2b8663977 100644 --- a/src/guest/off.sh +++ b/src/guest/off.sh @@ -7,11 +7,18 @@ # Needs from the caller: U, H (the desktop user and home), R (this copy of # src/), ROOT ("" in the VM; the tests' scratch folder), and the functions # log, user_ctl (systemctl --user for U), in_session (a command in U's -# session) and restart_shell_later. What is in the home goes as the user -# (home-files.sh), with ROOT in front of the path. +# session) and restart_shell_later; session_locked if it has one (the +# session is locked or its shell does not answer). What is in the home goes +# as the user (home-files.sh), with ROOT in front of the path. # shellcheck source=home-files.sh source "$(dirname "${BASH_SOURCE[0]}")/home-files.sh" +# off_locked: the caller says the desktop session is locked. Then nothing is +# written in ~/.config/omarchy/plugins and the shell is not asked to rescan: +# the shell of omarchy-mac 4.0.3 would reload its lock screen without the +# password field (guest/session-lock.sh). +off_locked() { declare -F session_locked >/dev/null && session_locked; } + # off_any PATH...: one of them is there (a file, folder or link) off_any() { local p @@ -73,25 +80,34 @@ gestures_off() { omanotch_off() { local n=$H/.local/bin/notchcast u=$H/.config/systemd/user/notchcast.service local q=/etc/systemd/user/omacvm-omanotch.service hook=/etc/pacman.d/hooks/zz-omacvm-omanotch-notifications.hook - local panel=$H/.config/omarchy/plugins/omanotch.monitor + local panel=$H/.config/omarchy/plugins/omanotch.monitor tool=$H/.local/bin/omanotch-display-panel local copies=$H/.config/systemd/user/omanotch-copies.service chook=/etc/pacman.d/hooks/zz-omacvm-omanotch-copies.hook off_any "$n" "$u" "$u.d" "$q" "$hook" "$H/.config/hypr/notchbar.lua" "$H/.local/state/omacvm/omanotch" "$panel" \ - "$copies" "$chook" "$H/.local/lib/omanotch" || return 0 + "$copies" "$chook" "$H/.local/lib/omanotch" "$tool" || return 0 log "Omanotch: off" user_units_off omacvm-omanotch.service omanotch-copies.service rm -f "$ROOT$q" "$ROOT$hook" "$ROOT$chook" "$ROOT/usr/local/lib/omacvm/omanotch-copies-hook" home_run rm -rf "$ROOT$copies" "$ROOT$H/.local/lib/omanotch" [[ -z $("$R/guest/omanotch-notifications.sh" off 2>/dev/null || true) ]] || restart_shell_later - # Omanotch's own uninstall needs the session (Hyprland, the shell); the - # files go here too, so it is off also without one. - if off_any "$n" "$u"; then in_session bash "$R/omanotch/guest/uninstall.sh" >/dev/null 2>&1 || true; fi - # Its display panel clone (UTM, Fusion, Parallels): without the session, - # Omarchy's panel goes back in its place in shell.json itself (as the user). - if [[ -d $ROOT$panel ]]; then - XDG_CONFIG_HOME=$ROOT$H/.config home_run python3 "$R/omanotch/guest/monitor/display-panel.py" --drop >/dev/null 2>&1 < /dev/null || true + if off_locked; then + # Its own uninstall removes its clones of Omarchy's plugins and asks the + # shell to rescan: under the lock that waits for the unlock + # (guest/restart-shell.sh runs it then; the panel tool, kept until then, + # says it is owed). Nothing of it runs or talks to the Mac meanwhile. + log "Omanotch: its clones of Omarchy's plugins go right after the next unlock" + else + # Omanotch's own uninstall needs the session (Hyprland, the shell); the + # files go here too, so it is off also without one. + if off_any "$n" "$u" "$tool"; then in_session bash "$R/omanotch/guest/uninstall.sh" >/dev/null 2>&1 || true; fi + # Its display panel clone (UTM, Fusion, Parallels): without the session, + # Omarchy's panel goes back in its place in shell.json itself (as the user). + if [[ -d $ROOT$panel ]]; then + XDG_CONFIG_HOME=$ROOT$H/.config home_run python3 "$R/omanotch/guest/monitor/display-panel.py" --drop >/dev/null 2>&1 < /dev/null || true + fi + home_run rm -rf "$ROOT$tool" fi user_units_off notchcast.service - home_run rm -rf "$ROOT$u" "$ROOT$u.d" "$ROOT$n" "$ROOT$H/.local/bin/omanotch-display-panel" "$ROOT$H/.config/hypr/notchbar.lua" \ + home_run rm -rf "$ROOT$u" "$ROOT$u.d" "$ROOT$n" "$ROOT$H/.config/hypr/notchbar.lua" \ "$ROOT$H/.local/state/omacvm/omanotch" "$ROOT$H/.local/state/omanotch/expect" off_lines "$H/.config/hypr/hyprland.lua" '-- omarchy-notch-bar: hidden output for the macOS notch helper.' 'require("hypr.notchbar")' } diff --git a/src/guest/restart-shell.sh b/src/guest/restart-shell.sh index 7c53e2a41..eba667cb8 100755 --- a/src/guest/restart-shell.sh +++ b/src/guest/restart-shell.sh @@ -1,49 +1,127 @@ #!/bin/bash -# restart-shell.sh [--after-unlock]: the Omarchy shell again, so the bar -# widgets an update changed load (guest/install.sh runs it as the desktop -# user, at the end of an apply). Never while the session is locked: Omarchy's -# lock screen lives in the shell, and a restart leaves Hyprland's "lockscreen -# app died" screen behind. Locked: a user unit (omacvm-restart-shell) runs -# this again with --after-unlock, which restarts the shell right after the -# unlock. It ends without one when the shell stops answering (the session -# ended, or the shell restarted anyway: either way the next shell loads the -# new widgets). +# restart-shell.sh [--after-unlock | --retire]: what an apply owes the running +# Omarchy shell, done while the session is open: copies of bar widgets staged +# under the lock go in place and queued ones are enabled (omacvm-plugins), +# the colour fix reaches the user's clones of Omarchy's plugins +# (shell-colors.py), Omanotch's own uninstall runs if Omanotch was switched +# off under the lock, then the shell restarts once if a widget it had loaded +# changed (the flag ~/.local/state/omacvm/restart-shell). Run as the desktop +# user, at the end of an apply (guest/install.sh) and at login +# (omacvm-plugins.service). # -# Locked = Omarchy's lock status (requested, sessionLocked or secure, read at -# the call). Not `omarchy-shell lock isLocked`: with Quickshell 0.3.2 and -# older it answers true after the first unlock until the shell restarts -# (omarchy-mac#738; Quickshell 18c95fae, Omarchy's own fix omacom/omarchy#14978). +# Never while the session is locked (session-lock.sh; busy counts as locked): +# Omarchy's lock screen lives in the shell. A restart leaves Hyprland's +# "lockscreen app died" screen behind, and a plugin write or rescan brings +# the lock screen back without its password field. Locked: a user unit +# (omacvm-restart-shell) runs this again with --after-unlock, which does it +# right after the unlock. It ends without doing anything when the shell is +# gone (logout, shutdown): the files stay, and the next login does it. +# +# --retire (guest/install.sh, at its start): such a unit of an earlier apply +# stops, so that it never works beside this apply: at once while the session +# answers "locked" (it only waits then), else once it is done (90 s at most: +# it may be restarting the shell, and stopped then, there would be no shell +# and no lock screen). What it owed is in files; the end of the apply starts +# one again, with the newest code. source /usr/share/omarchy/default/bash/env-bootstrap 2>/dev/null # Update VM runs this without a locale. qs then prints Qt's locale warning # before each answer, which also broke omarchy-restart-shell's own lock check # (it took a live lock for a dead one: killed the lock screen and locked again). export LC_ALL=C.UTF-8 +here=$(cd "$(dirname "$0")" && pwd) +self=$here/$(basename "$0") +# shellcheck source=session-lock.sh +source "$here/session-lock.sh" +ST=${XDG_STATE_HOME:-$HOME/.local/state}/omacvm +FLAG=$ST/restart-shell +UNIT=omacvm-restart-shell.service +PLUGINS=/usr/local/bin/omacvm-plugins +COLORS=/usr/local/lib/omacvm/shell-colors.py +ENV=/etc/omacvm/env +NOTCH_TOOL=$HOME/.local/bin/omanotch-display-panel + +# Omanotch switched off while the session was locked (guest/off.sh): its own +# uninstall (its clones of Omarchy's plugins, the shell's rescan) is owed. +omanotch_owed() { + [[ -e $NOTCH_TOOL && $(sed -n 's/^OMACVM_FEATURE_omanotch=//p' "$ENV" 2>/dev/null | tail -n 1) == off ]] +} +owed() { + local d + [[ -e $FLAG || -s $ST/pending-plugins || -s $ST/pending-plugins-off || -s $ST/pending-clock ]] && return 0 + for d in "$ST"/plugins-staged/*/; do [[ -d $d ]] && return 0; done + omanotch_owed +} +shell_pid() { pgrep -u "$(id -u)" -x quickshell 2>/dev/null | head -n 1; } -# The last line of an answer (in case the warning comes anyway). -ask() { omarchy-shell lock "$1" 2>/dev/null | tail -n 1; } -up() { omarchy-shell shell ping >/dev/null 2>&1; } -locked() { - local s - s=$(ask status) - if [[ $s == "{"* ]]; then - [[ $s == *'"requested":true'* || $s == *'"sessionLocked":true'* || $s == *'"secure":true'* ]] - else - [[ $(ask isLocked) == true ]] # an Omarchy without the status call +# Everything owed, the session open. False when Omarchy refused the restart +# (the session locked again meanwhile): the flag stays. +run_owed() { + local r before run=${XDG_RUNTIME_DIR:-/run/user/$(id -u)} + if [[ -x $PLUGINS ]]; then "$PLUGINS"; fi + if omanotch_owed; then + # In the session, as off.sh's in_session runs it (Hyprland's variables). + WAYLAND_DISPLAY=${WAYLAND_DISPLAY:-wayland-1} \ + HYPRLAND_INSTANCE_SIGNATURE=${HYPRLAND_INSTANCE_SIGNATURE:-$(ls -t "$run/hypr" 2>/dev/null | head -n 1)} \ + bash "$here/../omanotch/guest/uninstall.sh" >/dev/null 2>&1 + echo "Omanotch: its clones of Omarchy's plugins removed" + rm -f "$NOTCH_TOOL" fi + if [[ -x $COLORS && -d $HOME/.config/omarchy/plugins ]]; then + r=$("$COLORS" "$HOME/.config/omarchy/plugins" 2>&1) + case $r in patched*|restored*) echo "Omarchy shell colours: $r"; : > "$FLAG" ;; esac + fi + [[ -e $FLAG ]] || return 0 + before=$(shell_pid) + if ! omarchy-restart-shell >/dev/null 2>&1 && [[ $(shell_pid) == "$before" ]]; then + return 1 + fi + rm -f "$FLAG" + # An enable that came before the shell's rescan had read the new folder + # (it reads in the background): the new shell has it. + if [[ -s $ST/pending-plugins && -x $PLUGINS ]]; then "$PLUGINS"; fi + return 0 } -if [[ ${1:-} == --after-unlock ]]; then - while up && locked; do sleep 5; done - up || exit 0 - sleep 2 - exec omarchy-restart-shell -fi +case ${1:-} in + --retire) + end=$((SECONDS + 90)) n=0 + while (( SECONDS < end && n < 90 )); do + systemctl --user is-active --quiet "$UNIT" 2>/dev/null || exit 0 + [[ $(lock_state) == locked ]] && break + n=$((n + 1)) + sleep 1 + done + systemctl --user stop "$UNIT" 2>/dev/null + exit 0 ;; + --after-unlock) + down=0 tries=0 + while owed; do + case $(lock_state) in + open) + down=0 + sleep 2 + if [[ $(lock_state) == open ]]; then + run_owed && exit 0 + tries=$((tries + 1)) + (( tries < 3 )) || exit 0 + fi ;; + down) down=$((down + 1)); (( down < 2 )) || exit 0 ;; # twice: not a restart in between + *) down=0 ;; + esac + sleep 5 + done + exit 0 ;; +esac -up || exit 0 -if ! locked; then - omarchy-restart-shell >/dev/null 2>&1 +owed || exit 0 +case $(lock_state) in + down) rm -f "$FLAG"; exit 0 ;; # the next shell loads what is in place, the next login the rest + open) run_owed && exit 0 ;; +esac +if systemctl --user is-active --quiet "$UNIT" 2>/dev/null; then + # It reads the files when the session opens: this apply's are there. + echo "the Omarchy shell gets the new bar widgets right after the next unlock (already waiting)" exit 0 fi -self=$(cd "$(dirname "$0")" && pwd)/$(basename "$0") -systemd-run --user --quiet --collect --unit=omacvm-restart-shell bash "$self" --after-unlock 2>/dev/null || true -echo "the Omarchy shell restarts after the next unlock (new bar widgets)" +systemd-run --user --quiet --collect --unit="${UNIT%.service}" bash "$self" --after-unlock 2>/dev/null || true +echo "the Omarchy shell gets the new bar widgets right after the next unlock" diff --git a/src/guest/session-lock.sh b/src/guest/session-lock.sh new file mode 100644 index 000000000..2f4982f82 --- /dev/null +++ b/src/guest/session-lock.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# session-lock.sh: is the desktop session locked? Run as the desktop user, it +# prints one word; sourced, lock_state prints it: +# down no Omarchy shell (quickshell) of this user runs +# busy one runs but does not answer (starting, reloading, restarting) +# locked Omarchy's lock status says requested, sessionLocked or secure +# open it answers and is not locked +# Callers take busy as locked. +# +# While the session is locked nothing of OmacVM's writes under +# ~/.config/omarchy/plugins or asks the shell to rescan its plugins: the shell +# of omarchy-mac 4.0.3 then reloads every plugin, its own lock screen too, and +# the new lock screen has no password field until the shell restarts +# (Omarchy fixed it in omacom/omarchy#9485, omarchy-mac does not have it yet). +# Nor is the shell restarted then: Hyprland would show its "lockscreen app +# died" screen. +# +# Locked is Omarchy's lock status, read at the call. Not `omarchy-shell lock +# isLocked`: with Quickshell 0.3.2 and older it answers true after the first +# unlock until the shell restarts (omarchy-mac#738; Quickshell 18c95fae, +# Omarchy's own fix omacom/omarchy#14978). Omarchy's commands get +# LC_ALL=C.UTF-8: without a locale (root's sudo -u in an apply) qs prints Qt's +# locale warning before every answer. + +# The last line of an answer (in case the warning comes anyway). +session_ask() { LC_ALL=C.UTF-8 omarchy-shell "$@" 2>/dev/null | tail -n 1; } + +lock_state() { + local s + if ! LC_ALL=C.UTF-8 omarchy-shell shell ping >/dev/null 2>&1; then + if pgrep -u "$(id -u)" -x quickshell >/dev/null 2>&1; then echo busy; else echo down; fi + return 0 + fi + s=$(session_ask lock status) + if [[ $s == "{"* ]]; then + if [[ $s == *'"requested":true'* || $s == *'"sessionLocked":true'* || $s == *'"secure":true'* ]]; then + echo locked + else + echo open + fi + elif [[ $(session_ask lock isLocked) == true ]]; then + echo locked # an Omarchy without the status call + else + echo open + fi +} + +if [[ ${BASH_SOURCE[0]} == "$0" ]]; then + [[ -n ${OMARCHY_PATH:-} ]] || source /usr/share/omarchy/default/bash/env-bootstrap 2>/dev/null + lock_state +fi diff --git a/src/lib/install-plugin.sh b/src/lib/install-plugin.sh index de324b63e..e524bec3d 100755 --- a/src/lib/install-plugin.sh +++ b/src/lib/install-plugin.sh @@ -1,11 +1,18 @@ #!/bin/bash # Install one Omarchy shell plugin folder for the desktop user. Run as root: # lib/install-plugin.sh -# Copies it to ~/.config/omarchy/plugins// and validates it. Enabling needs -# the running Omarchy shell: if it runs, the plugin is enabled now; otherwise -# (fresh build, nobody logged in yet) it is queued and omacvm-plugins -# enables it at the next login. A plugin may ship placement.sh (run as the -# user) to choose its spot in the bar. +# Validates it and copies it to ~/.config/omarchy/plugins// (nothing is +# written when that folder has it already). Enabling needs the running +# Omarchy shell: if it runs, the plugin is enabled now; otherwise (fresh +# build, nobody logged in yet) it is queued and omacvm-plugins enables it at +# the next login. A plugin may ship placement.sh (run as the user) to choose +# its spot in the bar. +# While the session is locked nothing is written in the plugins folder: the +# shell of omarchy-mac 4.0.3 would reload its lock screen without the +# password field (guest/session-lock.sh). The copy waits in +# ~/.local/state/omacvm/plugins-staged/; right after the unlock +# guest/restart-shell.sh puts it in place (omacvm-plugins), before its one +# shell restart. set -euo pipefail here=$(cd "$(dirname "$0")" && pwd) U=${1:?usage: install-plugin.sh }; dir=$(cd "${2%/}" && pwd) @@ -21,33 +28,70 @@ as_user() { id=$(jq -r .id "$dir/manifest.json") dest=$H/.config/omarchy/plugins/$id +ST=$H/.local/state/omacvm # The user copies it: from a copy the user can read (the caller's folder may # be root's alone, as the app's display widget's temp folder). pub=$(mktemp -d); trap 'rm -rf "$pub"' EXIT cp -R "$dir/." "$pub/"; chmod -R a+rX,go-w "$pub"; dir=$pub -home_mkdir "$H/.config/omarchy/plugins" -# A running shell keeps the plugin it loaded: note real changes so -# guest/install.sh restarts the shell once at the end. -if [[ -d $dest ]] && ! home_run diff -rq "$dir" "$dest" >/dev/null 2>&1 < /dev/null; then - home_mkdir "$H/.local/state/omacvm" - home_put "$H/.local/state/omacvm/restart-shell" < /dev/null +# Checked before anything is written: a plugin the shell would refuse never +# takes the place of one that works. +as_user omarchy plugin validate "$dir" >/dev/null +home_mkdir "$H/.config/omarchy/plugins" "$ST" +# A copy an earlier apply staged under the lock: this one is newer. +home_run rm -rf "$ST/plugins-staged/$id" < /dev/null +# The folder in place has this content (and the same files executable). +xfiles() { + home_run find "$1" -type f -perm -u+x < /dev/null 2>/dev/null | + while IFS= read -r f; do printf '%s\n' "${f#"$1"}"; done | LC_ALL=C sort +} +same() { + [[ -d $dest ]] && home_run diff -rq "$dir" "$dest" >/dev/null 2>&1 < /dev/null && + [[ $(xfiles "$dir") == "$(xfiles "$dest")" ]] +} +state=$(as_user bash "$here/../guest/session-lock.sh" 2>/dev/null) || state=down +new=1 +if same; then + new=0 # nothing written: the shell reloads nothing and needs no restart for it +elif [[ $state == locked || $state == busy ]]; then + # Written beside, then renamed: the login's omacvm-plugins may be moving + # staged copies in right now, and must never take a half-written one. + home_mkdir "$ST/plugins-staged" + home_run rm -rf "$ST/plugins-staged/.$id.new" < /dev/null + home_run cp -r "$dir" "$ST/plugins-staged/.$id.new" < /dev/null + home_run mv "$ST/plugins-staged/.$id.new" "$ST/plugins-staged/$id" < /dev/null +else + # A running shell keeps the plugin it loaded: noted, so guest/install.sh + # restarts the shell once at the end (guest/restart-shell.sh). No shell: + # the next one loads this copy. + if [[ $state == open && -d $dest ]]; then home_put "$ST/restart-shell" < /dev/null; fi + home_run rm -rf "$dest" < /dev/null + home_run cp -r "$dir" "$dest" < /dev/null fi -home_run rm -rf "$dest" -home_run cp -r "$dir" "$dest" -as_user omarchy plugin validate "$dest" >/dev/null install -m755 "$here/omacvm-plugins" /usr/local/bin/omacvm-plugins install -m644 "$here/omacvm-plugins.service" /etc/systemd/user/omacvm-plugins.service -Q=$H/.local/state/omacvm/pending-plugins -home_mkdir "$(dirname "$Q")" -home_grep "$Q" -qx "$id" || echo "$id" | home_append "$Q" +Q=$ST/pending-plugins # On again before a disable queued at the next login ran: not disabled. if [[ -f $Q-off ]]; then { grep -vxF "$id" < <(home_cat "$Q-off") || true; } | home_put "$Q-off"; fi -if as_user omarchy-shell shell ping >/dev/null 2>&1; then +# Queued for the shell (rescan, enable, its place in the bar) only when there +# is something to do: a new or changed copy, or the plugin is not in +# shell.json (the user took it off the bar: placement.sh puts it back). An +# unchanged plugin in its place costs the shell nothing. +in_config() { + jq -e --arg id "$id" '[.bar.layout[]?[]?.id, .plugins[]?.id] | index($id) != null' \ + < <(home_cat "$H/.config/omarchy/shell.json") >/dev/null 2>&1 +} +queued=0 +if (( new )) || ! in_config; then + home_grep "$Q" -qx "$id" || echo "$id" | home_append "$Q" + queued=1 +fi +if [[ $state == open ]]; then as_user /usr/local/bin/omacvm-plugins -else +elif (( queued )); then systemctl --user -M "$U@" enable omacvm-plugins.service >/dev/null 2>&1 || { home_mkdir "$H/.config/systemd/user/graphical-session.target.wants" home_run ln -sf /etc/systemd/user/omacvm-plugins.service "$H/.config/systemd/user/graphical-session.target.wants/"; } - echo "plugin $id installed (enabled at the next login)" + if [[ $state == down ]]; then echo "plugin $id installed (enabled at the next login)" + else echo "plugin $id: in place right after the next unlock (the session is locked)"; fi fi diff --git a/src/lib/omacvm-plugins b/src/lib/omacvm-plugins index a60267981..5d6615dc1 100755 --- a/src/lib/omacvm-plugins +++ b/src/lib/omacvm-plugins @@ -2,15 +2,52 @@ # Enable the OmacVM bar widgets queued in ~/.local/state/omacvm/pending-plugins, # disable the ones in pending-plugins-off (a feature switched off while nobody # was logged in) and set the clock queued in pending-clock, once the Omarchy -# shell answers. Run as the desktop user (at login by -# omacvm-plugins.service, or directly by lib/install-plugin.sh). +# shell answers. Copies staged while the session was locked +# (plugins-staged/, lib/install-plugin.sh) go in place first. Run as the +# desktop user (at login by omacvm-plugins.service, directly by +# lib/install-plugin.sh, after an unlock by guest/restart-shell.sh). +# Nothing of it while the session is locked (guest/session-lock.sh): a write +# in the plugins folder or a rescan would reload Omarchy's lock screen without +# its password field. It all stays queued for restart-shell.sh's job after +# the unlock. set -uo pipefail [[ -n ${OMARCHY_PATH:-} ]] || source /usr/share/omarchy/default/bash/env-bootstrap 2>/dev/null -Q=${XDG_STATE_HOME:-$HOME/.local/state}/omacvm/pending-plugins -CL=${XDG_STATE_HOME:-$HOME/.local/state}/omacvm/pending-clock -OFF=${XDG_STATE_HOME:-$HOME/.local/state}/omacvm/pending-plugins-off -[[ -s $Q || -s $CL || -s $OFF ]] || exit 0 +ST=${XDG_STATE_HOME:-$HOME/.local/state}/omacvm +Q=$ST/pending-plugins +CL=$ST/pending-clock +OFF=$ST/pending-plugins-off +STAGED=$ST/plugins-staged +PL=$HOME/.config/omarchy/plugins +staged() { local d; for d in "$STAGED"/*/; do [[ -d $d ]] && return 0; done; return 1; } +[[ -s $Q || -s $CL || -s $OFF ]] || staged || exit 0 +# One run at a time (the login's and an after-unlock job can meet). +if command -v flock >/dev/null; then exec 9>> "$ST/.plugins.lock"; flock -w 90 9 || exit 0; fi for _ in $(seq 60); do omarchy-shell shell ping >/dev/null 2>&1 && break; sleep 1; done +state=open +if [[ -r /usr/local/share/omacvm/guest/session-lock.sh ]]; then + # shellcheck source=../guest/session-lock.sh + source /usr/local/share/omacvm/guest/session-lock.sh + state=$(lock_state) +fi +case $state in + locked|busy) echo "bar widgets: after the next unlock (the session is locked)"; exit 0 ;; +esac +# Staged copies in place, each by renames (the old folder hidden first, which +# the shell's watcher skips). A running shell has the old one loaded: +# restart-shell.sh restarts it once. +for d in "$STAGED"/*/; do + d=${d%/}; id=${d##*/} + [[ -d $d ]] || continue + dest=$PL/$id old=$PL/.$id.old + mkdir -p "$PL"; rm -rf "$old" + if [[ -e $dest ]]; then + mv "$dest" "$old" || continue + [[ $state == open ]] && : > "$ST/restart-shell" + fi + if mv "$d" "$dest"; then echo "plugin $id: the new copy in place" + elif [[ -e $old && ! -e $dest ]]; then mv "$old" "$dest"; fi + rm -rf "$old" +done if [[ -s $OFF ]] && omarchy-shell shell ping >/dev/null 2>&1; then while read -r id; do [[ -n $id ]] && omarchy plugin disable "$id" >/dev/null 2>&1 && echo "plugin $id disabled" @@ -25,10 +62,10 @@ omarchy-shell -q shell rescanPlugins left=() while read -r id; do [[ -n $id ]] || continue - dir=$HOME/.config/omarchy/plugins/$id + dir=$PL/$id if [[ -x $dir/placement.sh ]]; then (cd "$dir" && ./placement.sh) && ok=1 || ok=0 else omarchy plugin enable "$id" >/dev/null && ok=1 || ok=0; fi if (( ok )); then echo "plugin $id enabled"; else left+=("$id"); fi done < "$Q" -printf '%s\n' "${left[@]}" | grep . > "$Q.new" || true +printf '%s\n' ${left[@]+"${left[@]}"} | grep . > "$Q.new" || true mv "$Q.new" "$Q" diff --git a/src/lib/omacvm-plugins.service b/src/lib/omacvm-plugins.service index da9d41d67..653a8c8a2 100644 --- a/src/lib/omacvm-plugins.service +++ b/src/lib/omacvm-plugins.service @@ -5,7 +5,12 @@ PartOf=graphical-session.target [Service] Type=oneshot -ExecStart=/usr/local/bin/omacvm-plugins +# A restart noted before this login is done: this shell loaded what is in place. +ExecStartPre=-/bin/rm -f %h/.local/state/omacvm/restart-shell +ExecStart=-/usr/local/bin/omacvm-plugins +# Copies staged while the session was locked and moved in above: one restart +# of the shell if it had loaded the old ones (never while locked). +ExecStart=-/bin/bash /usr/local/share/omacvm/guest/restart-shell.sh [Install] WantedBy=graphical-session.target diff --git a/src/omanotch/guest/monitor/display-panel.py b/src/omanotch/guest/monitor/display-panel.py index 647954b74..56ae3c662 100755 --- a/src/omanotch/guest/monitor/display-panel.py +++ b/src/omanotch/guest/monitor/display-panel.py @@ -33,6 +33,7 @@ VERSION = 1 VERSION_LINE = f"// omarchy-notch-bar display panel patch v{VERSION}" CLONE_ID = "omanotch.monitor" +LOCKED = "the session is locked: at the next start" HELPERS = f""" // --- omarchy-notch-bar ------------------------------------------------ {VERSION_LINE} @@ -99,6 +100,24 @@ def run(*cmd, timeout=10): return "" +def session_locked(): + """Omarchy's lock screen is up or on its way: its lock status, read at the + call (requested, sessionLocked or secure), as copies.py reads it. Under + the lock nothing is written in the plugins folder and nothing rescanned: + the shell of omarchy-mac 4.0.3 reloads every plugin then, its lock screen + too, and the new one has no password field. Not isLocked: with Quickshell + 0.3.2 and older it answers true after the first unlock until the shell + restarts (omarchy-mac#738).""" + def last(text): + lines = text.strip().splitlines() # in a C locale qs prints Qt's warning first + return lines[-1].strip() if lines else "" + try: + status = json.loads(last(run("omarchy-shell", "lock", "status"))) + return any(status.get(k) is True for k in ("requested", "sessionLocked", "secure")) + except (ValueError, AttributeError): # an Omarchy without the status call + return last(run("omarchy-shell", "lock", "isLocked")) == "true" + + def source_digest(source): """Omarchy's whole panel folder and this patch's version, as one hash.""" h = hashlib.sha256(f"patch v{VERSION}\n".encode()) @@ -114,6 +133,11 @@ def staged(clone): return clone.with_name(f".{clone.name}.new"), clone.with_name(f".{clone.name}.old") +def leftover(clone): + """A build was cut off: its stage or the old clone is still there.""" + return any(p.exists() for p in staged(clone)) + + def tidy(clone): """After a build that was cut off: the stage goes, and the old clone gets its place back if the new one never got there.""" @@ -126,11 +150,16 @@ def tidy(clone): old.rename(clone) +def current(clone, source): + """The clone is built from Omarchy's panel as it is now.""" + stamp = clone / ".source-sha256" + return stamp.exists() and stamp.read_text().strip() == source_digest(source) + + def build(clone, source): """Fill the clone from Omarchy's panel, patched. Returns what it did.""" digest = source_digest(source) - stamp = clone / ".source-sha256" - if stamp.exists() and stamp.read_text().strip() == digest: + if current(clone, source): return "already patched" patched = patch((source / "Panel.qml").read_text()) new, old = staged(clone) @@ -242,8 +271,15 @@ def main(argv): plugins = plugins_dir() clone = plugins / CLONE_ID if mode in ("--remove", "--drop"): + if mode == "--remove" and (clone.exists() or leftover(clone)) and session_locked(): + print(LOCKED) + return 0 remove(clone, shell=mode == "--remove") return 0 + # tidy() can move the old clone back in place: a write in the plugins folder. + if leftover(clone) and session_locked(): + print(LOCKED) + return 0 tidy(clone) if (plugins / "omacvm.monitor").is_dir(): print("OmacVM.app's display panel leaves NOTCH out already") @@ -260,8 +296,17 @@ def main(argv): return 0 try: patch((source / "Panel.qml").read_text()) + fits = None except ValueError as error: - print(f"Omarchy's display panel changed ({error}): Omarchy's own panel is used") + fits = error + # Anything to write or to ask the shell (--refresh: only after an Omarchy + # update): not while the session is locked. + if (fits or not current(clone, source) or (mode != "--refresh" and not enabled(CLONE_ID))) \ + and session_locked(): + print(LOCKED) + return 0 + if fits: + print(f"Omarchy's display panel changed ({fits}): Omarchy's own panel is used") remove(clone) return 0 result = build(clone, source) diff --git a/src/omanotch/guest/tests/test_display_panel.py b/src/omanotch/guest/tests/test_display_panel.py index 9fc3e2977..9dedfbb45 100644 --- a/src/omanotch/guest/tests/test_display_panel.py +++ b/src/omanotch/guest/tests/test_display_panel.py @@ -126,6 +126,8 @@ def setUp(self): self.enabled = [] self.disabled = [] self.no_shell = False + self.lock_status = "" # Omarchy's lock status answer ("" = no such call) + self.is_locked = "" patcher = mock.patch.object(dp, "run", side_effect=self.fake_run) patcher.start() self.addCleanup(patcher.stop) @@ -142,8 +144,106 @@ def fake_run(self, *cmd, timeout=None): return f"Disabled {cmd[1]}\n" if cmd[0] == "omarchy-plugin-list": return json.dumps([{"id": i, "enabled": True} for i in self.enabled[-1:]]) + if cmd == ("omarchy-shell", "lock", "status"): + return self.lock_status + if cmd == ("omarchy-shell", "lock", "isLocked"): + return self.is_locked return "" + def lock(self, locked=True): + self.lock_status = json.dumps({"locked": locked, "requested": locked, "sessionLocked": locked, + "secure": locked, "lastEvent": "x"}) + "\n" + + def shell_calls(self): + """The calls that write through the shell or reload its plugins.""" + return [c for c in self.calls if c[:2] != ("omarchy-shell", "lock")] + + # Under Omarchy's lock nothing is written in the plugins folder and the + # shell is not asked to rescan: the shell of omarchy-mac 4.0.3 reloads its + # lock screen with them, and the new one has no password field. + def test_locked_first_install(self): + self.lock() + self.assertEqual(self.main(), 0) + self.assertFalse(self.clone.exists()) + self.assertEqual(self.shell_calls(), []) + self.assertFalse(list(self.plugins.iterdir())) + + def test_locked_refresh_after_omarchy_update(self): + self.main() + before = (self.clone / "Panel.qml").read_text() + (self.source / "Panel.qml").write_text(PANEL.replace('id: root', 'id: root\n // new')) + self.lock() + self.calls.clear() + self.assertEqual(self.main("--refresh"), 0) + self.assertEqual((self.clone / "Panel.qml").read_text(), before) + self.assertEqual(self.shell_calls(), []) + self.assertEqual(sorted(p.name for p in self.plugins.iterdir()), ["omanotch.monitor"]) + self.lock(False) # the next start, unlocked: rebuilt + self.main("--refresh") + self.assertIn("// new", (self.clone / "Panel.qml").read_text()) + + def test_locked_refresh_unchanged_asks_nothing(self): + self.main() + self.lock() + self.calls.clear() + self.main("--refresh") + self.assertEqual(self.calls, []) + + def test_locked_panel_no_longer_fits(self): + self.main() + (self.source / "Panel.qml").write_text(PANEL.replace("Quickshell.screens.length", "screens.count")) + self.lock() + self.disabled.clear() + self.main("--refresh") + self.assertTrue(self.clone.exists()) + self.assertEqual(self.disabled, []) + + def test_locked_remove(self): + self.main() + self.lock() + self.calls.clear() + self.main("--remove") + self.assertTrue(self.clone.exists()) + self.assertEqual(self.shell_calls(), []) + + def test_locked_cut_off_between_renames(self): + # The old clone is not moved back under the lock (a write the shell sees). + self.main() + old = self.plugins / ".omanotch.monitor.old" + self.clone.rename(old) + self.lock() + self.calls.clear() + for mode in ("--refresh", "", "--remove"): + self.main(*([mode] if mode else [])) + self.assertFalse(self.clone.exists(), mode) + self.assertTrue(old.is_dir(), mode) + self.assertEqual(self.shell_calls(), [], mode) + self.lock(False) # the next start, unlocked + self.main("--refresh") + self.assertTrue((self.clone / "Panel.qml").is_file()) + self.assertFalse(old.exists()) + + def test_locked_leftover_stage_asks_once(self): + self.main() + (self.plugins / ".omanotch.monitor.new").mkdir() + self.lock() + self.calls.clear() + self.main("--refresh") + self.assertTrue((self.plugins / ".omanotch.monitor.new").is_dir()) + self.assertEqual(self.calls, [("omarchy-shell", "lock", "status")]) + + def test_lock_status_read_as_omarchy_says_it(self): + warn = " WARN: Detected locale \"C\" ...\nQt depends on a UTF-8 locale, and has switched to \"C.UTF-8\" instead.\n" + for status, locked in (({"requested": True}, True), ({"sessionLocked": True}, True), + ({"secure": True}, True), ({"locked": True, "requested": False}, False)): + self.lock_status = warn + json.dumps(status) + "\n" + self.assertEqual(dp.session_locked(), locked, status) + self.lock_status = "" # no status call: isLocked + self.is_locked = warn + "true\n" + self.assertTrue(dp.session_locked()) + self.is_locked = "" # no shell + self.assertFalse(dp.session_locked()) + def main(self, *args): with mock.patch("builtins.print"): return dp.main(["display-panel.py", *args]) diff --git a/src/tests/features-off.sh b/src/tests/features-off.sh index 83f86d7a3..62048ea34 100755 --- a/src/tests/features-off.sh +++ b/src/tests/features-off.sh @@ -66,6 +66,7 @@ run_off() { # FUNCTION [SESSION=0|1] [SERVICE=enabled|active|none] pkill() { echo "pkill $*" >> "$CALLS"; } chown() { :; } restart_shell_later() { echo "restart-shell" >> "$CALLS"; } + session_locked() { (( ${LOCKED:-0} )); } # LOCKED=1: Omarchy's session is locked /repo/guest/omanotch-notifications.sh() { :; } # /repo's Python helpers run from this checkout. SRC=$R/src; python3() { echo "python3 $*" >> "$CALLS"; command python3 "${@/#\/repo\//$SRC/}"; } @@ -131,6 +132,27 @@ expect "omanotch off: notchcast stopped" yes "$(called "user_ctl disable --now n : > "$OUT"; : > "$CALLS"; run_off omanotch_off 0 expect "omanotch off again: nothing to do" "" "$(cat "$OUT" "$CALLS")" +# Omanotch built and running, the session locked: its own uninstall (its +# clones in ~/.config/omarchy/plugins, a rescan) would bring Omarchy's lock +# screen back without its password field: it waits for the unlock +# (guest/restart-shell.sh), the panel tool kept says it is owed. Nothing of +# Omanotch runs or talks to the Mac meanwhile. +reset_root +file "$H/.local/bin/notchcast"; file "$H/.local/bin/omanotch-display-panel"; file "$H/.config/systemd/user/notchcast.service" +file "$H/.config/hypr/notchbar.lua"; file /etc/pacman.d/hooks/zz-omacvm-omanotch-notifications.hook +file "$H/.config/omarchy/plugins/omanotch.monitor/manifest.json" '{"id": "omanotch.monitor", "omarchy": {"clonedFrom": "omarchy.monitor"}}' +file "$H/.config/omarchy/shell.json" '{"bar": {"layout": {"right": [{"id": "omanotch.monitor"}]}}}' +LOCKED=1 run_off omanotch_off 1 +expect "omanotch off, locked: no uninstall in the session, nothing written in the plugins folder" "no no yes" \ + "$(called "in_session bash /repo/omanotch/guest/uninstall.sh") $(called "display-panel.py") $(has "$H/.config/omarchy/plugins/omanotch.monitor")" +expect "omanotch off, locked: notchcast stopped and gone, its hook gone" "yes no no" \ + "$(called "user_ctl disable --now notchcast.service") $(has "$H/.local/bin/notchcast") $(has /etc/pacman.d/hooks/zz-omacvm-omanotch-notifications.hook)" +expect "omanotch off, locked: the panel tool kept (the uninstall is owed)" yes "$(has "$H/.local/bin/omanotch-display-panel")" +expect "omanotch off, locked: says when" "Omanotch: off|Omanotch: its clones of Omarchy's plugins go right after the next unlock" "$(paste -sd'|' - < "$OUT")" +: > "$OUT"; : > "$CALLS"; run_off omanotch_off 1 +expect "omanotch off, the next apply unlocked: the uninstall in the session, the panel tool gone" "yes no" \ + "$(called "in_session bash /repo/omanotch/guest/uninstall.sh") $(has "$H/.local/bin/omanotch-display-panel")" + # The Bridge, nobody logged in: services, client, queued widgets; the widgets # in the bar are disabled at the next login. reset_root diff --git a/src/tests/plugin-lock.sh b/src/tests/plugin-lock.sh new file mode 100755 index 000000000..330f9dbbb --- /dev/null +++ b/src/tests/plugin-lock.sh @@ -0,0 +1,297 @@ +#!/bin/bash +# Bar widgets and Omarchy's lock screen, without a VM. The shell of +# omarchy-mac 4.0.3 reloads every plugin, its lock screen too, on a write +# under ~/.config/omarchy/plugins or a rescanPlugins call; under the lock the +# new lock screen has no password field. So an apply on a locked VM writes +# nothing there (lib/install-plugin.sh stages the copy), omacvm-plugins does +# nothing while locked, and guest/restart-shell.sh puts it all in place right +# after the unlock, with one shell restart. An unchanged plugin in its place +# costs nothing, locked or not. Runs the real scripts with stand-ins for +# Omarchy, systemd, sudo, getent and pgrep. +# src/tests/plugin-lock.sh [SRC] (SRC: another checkout's src/; v3.2.12's fails) +set -uo pipefail +R=$(cd "$(dirname "$0")/../.." && pwd) +SRC=$(cd "${1:-$R/src}" && pwd) +fail=0 +expect() { # WHAT WANT GOT + if [[ $2 == "$3" ]]; then echo "ok $1"; else echo "FAIL $1: want '$2', got '$3'"; fail=1; fi +} +T=$(mktemp -d); trap 'rm -rf "$T"' EXIT +unset XDG_STATE_HOME XDG_CONFIG_HOME +X=$T/root G=$T/src B=$T/bin S=$T/s H=$T/home +mkdir -p "$X/usr/local/bin" "$X/etc/systemd/user" "$X/usr/local/lib/omacvm" "$G" "$B" +# The scripts, their system paths moved into the test's folder. +cp -R "$SRC/lib" "$SRC/guest" "$G/" +for f in "$G/lib/install-plugin.sh" "$G/lib/omacvm-plugins" "$G/lib/omacvm-plugins.service" "$G/guest/restart-shell.sh"; do + [[ -f $f ]] || continue + s=$(sed -e "s#/usr/local/bin/#$X/usr/local/bin/#g" -e "s#/etc/systemd/#$X/etc/systemd/#g" \ + -e "s#/usr/local/share/omacvm/#$G/#g" -e "s#/usr/local/lib/omacvm/#$X/usr/local/lib/omacvm/#g" "$f") + printf '%s\n' "$s" > "$f" +done + +# Stand-ins. The shell: answers while $S/up exists, runs while $S/proc does +# (its pid in it), its lock status in $S/status. Every call goes to $CALLS. +cat > "$B/getent" < "$B/sudo" +cat > "$B/omarchy-shell" <<'EOF' +#!/bin/bash +[[ $1 == -q ]] && shift +[[ -e $S/up ]] || exit 1 +case "$1 $2" in + "shell ping") exit 0 ;; + "shell rescanPlugins") echo "rescan $(cat "$HOME/.config/omarchy/plugins/omacvm.t/v" 2>/dev/null || echo none)" >> "$CALLS" ;; + "lock status") cat "$S/status" ;; + *) exit 1 ;; +esac +EOF +cat > "$B/omarchy" <<'EOF' +#!/bin/bash +case "$1 $2" in + "plugin validate") [[ -f $3/manifest.json && ! -e $3/INVALID ]] ;; + "plugin enable") + echo "enable $3" >> "$CALLS" + c=$HOME/.config/omarchy/shell.json + jq --arg id "$3" '.bar.layout.right += [{id: $id}]' "$c" > "$c.new" && mv "$c.new" "$c" ;; + "plugin disable") [[ -e $S/up ]] || exit 1; echo "disable $3" >> "$CALLS"; echo "Disabled $3" ;; + *) exit 1 ;; +esac +EOF +cat > "$B/pgrep" <<'EOF' +#!/bin/bash +[[ -f $S/proc ]] || exit 1 +cat "$S/proc" +EOF +cat > "$B/systemctl" <<'EOF' +#!/bin/bash +echo "systemctl $*" >> "$CALLS" +case " $* " in + *" is-active "*) [[ -e $S/active ]] ;; + *" stop "*) rm -f "$S/active" ;; +esac +EOF +cat > "$B/systemd-run" <<'EOF' +#!/bin/bash +echo "systemd-run" >> "$CALLS"; touch "$S/active" +EOF +cat > "$B/omarchy-restart-shell" <<'EOF' +#!/bin/bash +echo "omarchy-restart-shell" >> "$CALLS" +echo $(( $(cat "$S/proc") + 1 )) > "$S/proc" +EOF +printf '#!/bin/bash\nexit 0\n' > "$B/sleep" +chmod 755 "$B"/* +export PATH="$B:$PATH" CALLS=$T/calls S + +# The plugin: omacvm.t in versions v1, v2, v3, and v2 broken. +plugin() { # VERSION + local d=$T/p/$1 + mkdir -p "$d" + printf '{"schemaVersion":1,"id":"omacvm.t","name":"T","version":"1","kinds":["bar-widget"],"entryPoints":{"barWidget":"Widget.qml"}}\n' > "$d/manifest.json" + echo "Item {}" > "$d/Widget.qml"; echo "$1" > "$d/v" + cat > "$d/placement.sh" <<'EOF' +#!/bin/bash +set -euo pipefail +c=${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/shell.json +jq -e '[.bar.layout[]?[]?.id] | index("omacvm.t") != null' "$c" >/dev/null && exit 0 +omarchy-shell -q shell rescanPlugins +omarchy plugin enable omacvm.t +EOF + chmod 755 "$d/placement.sh" +} +plugin v1; plugin v2; plugin v3; plugin bad; touch "$T/p/bad/INVALID" +P=$H/.config/omarchy/plugins/omacvm.t +ST=$H/.local/state/omacvm +# status LOCKED: Omarchy's lock status as its shell answers it. +status() { printf '{"locked":%s,"requested":%s,"pending":false,"sessionLocked":%s,"secure":%s,"lastEvent":"x"}\n' "$1" "$1" "$1" "$1" > "$S/status"; } +open() { touch "$S/up"; echo 100 > "$S/proc"; status false; } +locked() { touch "$S/up"; echo 100 > "$S/proc"; status true; } +busy() { rm -f "$S/up"; echo 100 > "$S/proc"; } +down() { rm -f "$S/up" "$S/proc"; } +fresh() { # PLACED: a home with v1 in place (on the bar when PLACED is yes) or none + rm -rf "$H" "$S" "$CALLS"; mkdir -p "$H/.config/omarchy/plugins" "$S"; : > "$CALLS" + if [[ ${1:-} == yes ]]; then + cp -R "$T/p/v1" "$P" + printf '{"bar":{"layout":{"right":[{"id":"omacvm.t"}]}}}\n' > "$H/.config/omarchy/shell.json" + else + printf '{"bar":{"layout":{"right":[]}}}\n' > "$H/.config/omarchy/shell.json" + fi + open +} +install_plugin() { bash "$G/lib/install-plugin.sh" "$(id -un)" "$T/p/$1" 2>&1; } +plugins() { HOME=$H bash "$X/usr/local/bin/omacvm-plugins" 2>&1; } +restart_shell() { HOME=$H bash "$G/guest/restart-shell.sh" "$@" 2>&1; } +calls() { grep -E '^(rescan|enable|disable|systemd-run|omarchy-restart-shell|systemctl.* (enable|stop) )' "$CALLS" | paste -sd'|' -; } +ino() { ls -di "$1" 2>/dev/null | awk '{print $1}'; } +ver() { cat "$1/v" 2>/dev/null || echo none; } +has() { [[ -e $1 ]] && echo yes || echo no; } +queue() { paste -sd' ' - 2>/dev/null < "$ST/pending-plugins"; } + +# 1. The session open, a new plugin: in place, enabled at once. +fresh; install_plugin v1 >/dev/null; rc=$? +expect "open, new: in place, rescanned and enabled, no restart" "0 v1 rescan v1|rescan v1|enable omacvm.t no" "$rc $(ver "$P") $(calls) $(has "$ST/restart-shell")" +expect " nothing left queued" "" "$(queue)" + +# 2. Open, the same plugin on the bar: nothing written, nothing for the shell. +fresh yes; i=$(ino "$P"); install_plugin v1 >/dev/null +expect "open, unchanged: the folder untouched" "$i" "$(ino "$P")" +expect " no rescan, no enable, no queue, no restart" "|||no" "$(calls)|$(queue)||$(has "$ST/restart-shell")" + +# 3. Open, changed: in place, the shell restarts at the end of the apply. +fresh yes; install_plugin v2 >/dev/null +expect "open, changed: in place, restart noted" "v2 yes rescan v2" "$(ver "$P") $(has "$ST/restart-shell") $(calls)" + +# 4. Locked, changed: nothing under the plugins folder, the copy staged. +fresh yes; locked; i=$(ino "$P"); out=$(install_plugin v2); rc=$? +expect "locked, changed: the folder untouched" "0 $i v1" "$rc $(ino "$P") $(ver "$P")" +expect " staged, queued, no restart noted yet" "v2 omacvm.t no" "$(ver "$ST/plugins-staged/omacvm.t") $(queue) $(has "$ST/restart-shell")" +expect " no rescan or enable under the lock; the login unit on" "systemctl --user -M $(id -un)@ enable omacvm-plugins.service" "$(calls)" +expect " says so" "plugin omacvm.t: in place right after the next unlock (the session is locked)" "$out" + +# 5. Locked, a new plugin. +fresh; locked; install_plugin v1 >/dev/null +expect "locked, new: staged, not in the plugins folder" "no v1 omacvm.t" "$(has "$P") $(ver "$ST/plugins-staged/omacvm.t") $(queue)" +expect " no rescan or enable" "" "$(calls | tr '|' '\n' | grep -v '^systemctl')" + +# 6. Locked, unchanged and on the bar (and a copy an earlier apply staged): +# nothing to do at all, the staged copy goes (this one is newer). +fresh yes; locked; mkdir -p "$ST/plugins-staged"; cp -R "$T/p/v2" "$ST/plugins-staged/omacvm.t" +i=$(ino "$P"); out=$(install_plugin v1) +expect "locked, unchanged: folder untouched, staged copy gone" "$i no" "$(ino "$P") $(has "$ST/plugins-staged/omacvm.t")" +expect " no queue line, no call, nothing said" "|||" "$(queue)|$(calls)|$out|" + +# 7. Locked twice: the newest copy waits. +fresh yes; locked; install_plugin v2 >/dev/null; install_plugin v3 >/dev/null +expect "locked twice: v3 staged, v1 in place" "v3 v1 omacvm.t" "$(ver "$ST/plugins-staged/omacvm.t") $(ver "$P") $(queue)" +expect " never half-written beside it" "" "$(ls -A "$ST/plugins-staged" | grep -v '^omacvm.t$')" + +# 8. Busy (the shell runs, does not answer: starting, reloading): as locked. +fresh yes; busy; i=$(ino "$P"); install_plugin v2 >/dev/null +expect "busy: staged, folder untouched" "v2 $i" "$(ver "$ST/plugins-staged/omacvm.t") $(ino "$P")" + +# 9. No shell (nobody logged in): in place now, no restart noted, enabled at login. +fresh yes; down; out=$(install_plugin v2) +expect "no shell: in place, no restart noted, queued" "v2 no omacvm.t" "$(ver "$P") $(has "$ST/restart-shell") $(queue)" +expect " enabled at the next login" "plugin omacvm.t installed (enabled at the next login)" "$out" + +# 10. A broken plugin: refused before anything is written. +fresh yes; i=$(ino "$P"); install_plugin bad >/dev/null; rc=$? +expect "broken plugin: refused, the working one untouched" "1 $i v1 no" "$(( rc != 0 )) $(ino "$P") $(ver "$P") $(has "$ST/plugins-staged/omacvm.t")" + +# 11. omacvm-plugins while locked: nothing moves, everything stays queued. +fresh yes; locked; install_plugin v2 >/dev/null; : > "$CALLS"; plugins >/dev/null; rc=$? +expect "omacvm-plugins locked: rc 0, nothing moved, still queued" "0 v1 v2 omacvm.t" "$rc $(ver "$P") $(ver "$ST/plugins-staged/omacvm.t") $(queue)" +expect " no rescan or enable" "" "$(calls)" + +# 12. ... open: the staged copy in place before the rescan, restart noted. +open; plugins >/dev/null +expect "omacvm-plugins open: v2 in place, staged gone, restart noted" "v2 no yes" "$(ver "$P") $(has "$ST/plugins-staged/omacvm.t") $(has "$ST/restart-shell")" +expect " the rescan saw v2" "rescan v2" "$(calls)" +expect " nothing hidden left in the plugins folder" "omacvm.t" "$(ls -A "$H/.config/omarchy/plugins" | paste -sd' ' -)" + +# 13. ... no shell: in place, no restart noted (the next shell loads it). +fresh yes; locked; install_plugin v2 >/dev/null; down; plugins >/dev/null +expect "omacvm-plugins without a shell: in place, no restart noted" "v2 no" "$(ver "$P") $(has "$ST/restart-shell")" + +# 14. A copy still being written (.omacvm.t.new) is never taken. +fresh yes; mkdir -p "$ST/plugins-staged/.omacvm.t.new"; echo "omacvm.t" > "$ST/pending-plugins"; plugins >/dev/null +expect "a half-written staged copy stays where it is" "v1 yes" "$(ver "$P") $(has "$ST/plugins-staged/.omacvm.t.new")" + +# 15. The login unit: a stale restart note goes; staged copies go in place, +# and the shell restarts once if it had loaded the old one. +U_FILE=$G/lib/omacvm-plugins.service +expect "login unit: the stale note removed first" 1 "$(grep -c '^ExecStartPre=-/bin/rm -f %h/.local/state/omacvm/restart-shell$' "$U_FILE")" +expect "login unit: restart-shell.sh after omacvm-plugins" 1 \ + "$(grep '^ExecStart' "$U_FILE" | tail -1 | grep -c 'guest/restart-shell.sh$')" +login() { # the unit's lines, in order + local l + while IFS= read -r l; do + l=${l#*=}; l=${l#-}; l=${l//%h/$H} + HOME=$H bash -c "$l" >/dev/null 2>&1 + done < <(grep -E '^ExecStart(Pre)?=' "$U_FILE") +} +fresh yes; locked; install_plugin v2 >/dev/null; echo old > "$ST/restart-shell"; open; : > "$CALLS"; login +expect "login, the shell up: v2 in place, one restart, the note gone" "v2 1 no" \ + "$(ver "$P") $(grep -c '^omarchy-restart-shell' "$CALLS") $(has "$ST/restart-shell")" +fresh yes; locked; install_plugin v2 >/dev/null; echo old > "$ST/restart-shell"; down; : > "$CALLS"; login +expect "login, no shell: v2 in place, no restart" "v2 0" "$(ver "$P") $(grep -c '^omarchy-restart-shell' "$CALLS")" +fresh yes; mkdir -p "$ST"; echo old > "$ST/restart-shell"; : > "$CALLS"; login +expect "login, only a note from before: no restart" "0 no" "$(grep -c '^omarchy-restart-shell' "$CALLS") $(has "$ST/restart-shell")" + +# 16. Two applies while locked: one job waits, then the newest copy and one restart. +apply_end() { restart_shell >/dev/null; } +fresh yes; locked +install_plugin v2 >/dev/null; apply_end # apply 1 +restart_shell --retire >/dev/null; install_plugin v3 >/dev/null; apply_end # apply 2 +expect "two locked applies: the first job stopped, one waits" "systemd-run|systemctl --user stop omacvm-restart-shell.service|systemd-run yes" \ + "$(grep -E '^(systemd-run|systemctl --user stop)' "$CALLS" | paste -sd'|' -) $(has "$S/active")" +expect " under the lock: v1 in place, no restart, no rescan" "v1 0 0" "$(ver "$P") $(grep -c '^omarchy-restart-shell' "$CALLS") $(grep -c '^rescan' "$CALLS")" +open; : > "$CALLS"; restart_shell --after-unlock >/dev/null +expect " after the unlock: v3 in place, one restart, nothing owed" "v3 1 no no" \ + "$(ver "$P") $(grep -c '^omarchy-restart-shell' "$CALLS") $(has "$ST/plugins-staged/omacvm.t") $(has "$ST/restart-shell")" + +# 17. Locked, nothing changed: the end of the apply starts no job. +fresh yes; locked; install_plugin v1 >/dev/null; : > "$CALLS"; apply_end +expect "locked, nothing changed: no job, no restart" "" "$(calls)" + +# 18. The control centre off (control/guest/install.sh's bar_item_off): out of +# the bar, the folder gone; under the lock the folder stays (out of the bar +# it does nothing; the next apply removes it). A copy staged under the lock +# and a queued enable never come. +off_fn=$(awk '/^bar_item_off\(\) \{/ {on = 1} on {print} on && /^}$/ {exit}' "$SRC/control/guest/install.sh") +if [[ -n $off_fn ]]; then + mkdir -p "$G/control/guest" + cc_off() { + ( cd "$G/control/guest" || exit 9 + U=$(id -un) + as_user() { env HOME="$H" "$@"; } + source "$G/guest/home-files.sh" + eval "$off_fn" + bar_item_off ) + } + cc_fresh() { + fresh; cp -R "$T/p/v1" "$H/.config/omarchy/plugins/omacvm.control" + mkdir -p "$ST/plugins-staged"; cp -R "$T/p/v2" "$ST/plugins-staged/omacvm.control" + printf '%s\n' omacvm.control omacvm.t > "$ST/pending-plugins" + printf '{"bar":{"layout":{"right":[{"id":"omacvm.control"},{"id":"omacvm.t"}]}}}\n' > "$H/.config/omarchy/shell.json" + } + C=$H/.config/omarchy/plugins/omacvm.control + cc_fresh; locked; i=$(ino "$C"); cc_off + expect "control centre off, locked: out of the bar, the folder untouched" "disable omacvm.control $i" "$(calls) $(ino "$C")" + expect " the staged copy and the queued enable gone" "no omacvm.t" "$(has "$ST/plugins-staged/omacvm.control") $(queue)" + cc_fresh; cc_off + expect "control centre off, open: out of the bar, the folder gone" "disable omacvm.control no omacvm.t" "$(calls) $(has "$C") $(queue)" + cc_fresh; down; cc_off + expect "control centre off, no shell: out of shell.json by hand, the folder gone" "omacvm.t no" \ + "$(jq -r '[.bar.layout.right[].id] | join(" ")' "$H/.config/omarchy/shell.json") $(has "$C")" + I=$SRC/guest/install.sh + expect "install.sh runs the control centre's off while its folder or a staged copy is there" 1 \ + "$(grep -c 'd $H/.config/omarchy/plugins/omacvm.control || -d $H/.local/state/omacvm/plugins-staged/omacvm.control' "$I")" +else + expect "control/guest/install.sh has bar_item_off" yes no +fi + +# 19. Every rescanPlugins caller asks for the lock first, or runs only from +# omacvm-plugins (placement.sh). A new caller fails here until it does. +if [[ -z ${1:-} ]]; then + allowed="lib/omacvm-plugins app/guest/omacvm-displays omanotch/guest/monitor/display-panel.py omanotch/guest/uninstall.sh" + while IFS= read -r f; do + f=${f#"$SRC"/} + case $f in + tests/*|*/tests/*) continue ;; + */placement.sh) continue ;; # run by omacvm-plugins after its lock check + esac + if [[ " $allowed " != *" $f "* ]]; then expect "rescanPlugins in $f: on the list" yes no; continue; fi + case $f in + omanotch/guest/monitor/display-panel.py) # Omanotch's own rule (as copies.py) + expect "rescanPlugins in $f: asks for the lock first" yes "$(grep -q 'def session_locked' "$SRC/$f" && echo yes || echo no)" ;; + omanotch/guest/uninstall.sh) # run in the session only while it is open (guest/off.sh, restart-shell.sh) + expect "rescanPlugins in $f: off.sh runs it only unlocked" yes "$(grep -q 'off_locked' "$SRC/guest/off.sh" && echo yes || echo no)" ;; + *) expect "rescanPlugins in $f: asks for the lock first" yes "$(grep -q 'session-lock\.sh' "$SRC/$f" && echo yes || echo no)" ;; + esac + done < <(grep -rlI --exclude-dir=__pycache__ rescanPlugins "$SRC" | LC_ALL=C sort) +fi + +exit $fail diff --git a/src/tests/restart-shell.sh b/src/tests/restart-shell.sh index fac136f8d..200ba6d23 100755 --- a/src/tests/restart-shell.sh +++ b/src/tests/restart-shell.sh @@ -1,23 +1,36 @@ #!/bin/bash -# The Omarchy shell's restart for new bar widgets (src/guest/restart-shell.sh, -# run by guest/install.sh at the end of an apply) without a VM: restarted at -# once unless the session is locked, then right after the unlock. "Locked" is -# Omarchy's lock status read at the call, not isLocked, which Quickshell 0.3.2 -# leaves true after the first unlock (omarchy-mac#738). omarchy-shell, -# omarchy-restart-shell, systemd-run and sleep are stand-ins. +# What an apply owes the Omarchy shell (src/guest/restart-shell.sh, run by +# guest/install.sh at the end of an apply and by omacvm-plugins.service at +# login) without a VM: staged bar widgets in place (omacvm-plugins), the +# colour fix on the user's clones, one shell restart; at once unless the +# session is locked, then right after the unlock. "Locked" is Omarchy's lock +# status read at the call, not isLocked, which Quickshell 0.3.2 leaves true +# after the first unlock (omarchy-mac#738); a shell that does not answer +# (busy) counts as locked. omarchy-shell, omarchy-restart-shell, systemd-run, +# systemctl, pgrep, sleep, omacvm-plugins and shell-colors.py are stand-ins. # src/tests/restart-shell.sh [SCRIPT] (SCRIPT: another restart-shell.sh to test) set -uo pipefail R=$(cd "$(dirname "$0")/../.." && pwd) -K=${1:-$R/src/guest/restart-shell.sh} +K0=${1:-$R/src/guest/restart-shell.sh} fail=0 expect() { # WHAT WANT GOT if [[ $2 == "$3" ]]; then echo "ok $1"; else echo "FAIL $1: want '$2', got '$3'"; fail=1; fi } -[[ -f $K ]] || { echo "FAIL $K is missing"; exit 1; } +[[ -f $K0 ]] || { echo "FAIL $K0 is missing"; exit 1; } T=$(mktemp -d); trap 'rm -rf "$T"' EXIT -mkdir -p "$T/bin" -# omarchy-shell: up while $T/up exists; answers from $T/status and $T/islocked +mkdir -p "$T/bin" "$T/g" +# The script and session-lock.sh beside it, omacvm-plugins and shell-colors.py +# moved to the stand-ins. +for f in "$K0" "$(dirname "$K0")/session-lock.sh"; do + [[ -f $f ]] || continue + s=$(sed -e "s#/usr/local/bin/omacvm-plugins#$T/bin/omacvm-plugins#g" \ + -e "s#/usr/local/lib/omacvm/shell-colors.py#$T/bin/shell-colors.py#g" -e "s#/etc/omacvm/env#$T/env#g" "$f") + printf '%s\n' "$s" > "$T/g/$(basename "$f")" +done +K=$T/g/$(basename "$K0") +# omarchy-shell: answers while $S/up exists, from $S/status and $S/islocked # (absent: the call is unknown, exit 1 as Omarchy's omarchy-shell says it). +# The shell runs while $S/proc exists (its pid in it). cat > "$T/bin/omarchy-shell" <<'EOF' #!/bin/bash echo "omarchy-shell $*" >> "$CALLS" @@ -29,51 +42,98 @@ case "$1 $2" in *) exit 1 ;; esac EOF +cat > "$T/bin/pgrep" <<'EOF' +#!/bin/bash +[[ -f $S/proc ]] || exit 1 +cat "$S/proc" +EOF +# omarchy-restart-shell: refuses (exit 1, the same shell) while $S/refuse exists. cat > "$T/bin/omarchy-restart-shell" <<'EOF' #!/bin/bash echo "omarchy-restart-shell" >> "$CALLS" +if [[ -e $S/refuse ]]; then rm -f "$S/refuse"; exit 1; fi +[[ -f $S/proc ]] && echo $(( $(cat "$S/proc") + 1 )) > "$S/proc" +exit 0 EOF cat > "$T/bin/systemd-run" <<'EOF' #!/bin/bash echo "systemd-run $*" >> "$CALLS" EOF -# sleep: counts; $S/after-N runs (a script) after the Nth one +# systemctl --user is-active: while $S/active exists; stop removes it. +cat > "$T/bin/systemctl" <<'EOF' +#!/bin/bash +echo "systemctl $*" >> "$CALLS" +case " $* " in + *" is-active "*) [[ -e $S/active ]] ;; + *" stop "*) rm -f "$S/active" ;; +esac +EOF +# omacvm-plugins: moves the staged copies (and notes the restart when +# $S/replaces exists); shell-colors.py: prints $S/colors. +cat > "$T/bin/omacvm-plugins" <<'EOF' +#!/bin/bash +echo "omacvm-plugins" >> "$CALLS" +st=$HOME/.local/state/omacvm +if [[ -d $st/plugins-staged ]]; then rm -rf "$st/plugins-staged"; [[ -e $S/replaces ]] && touch "$st/restart-shell"; fi +exit 0 +EOF +# Omanotch's uninstall, beside this copy as in the VM (../omanotch/guest). +mkdir -p "$T/omanotch/guest" +cat > "$T/omanotch/guest/uninstall.sh" <<'EOF' +echo "omanotch-uninstall $WAYLAND_DISPLAY $HYPRLAND_INSTANCE_SIGNATURE" >> "$CALLS" +EOF +cat > "$T/bin/shell-colors.py" <<'EOF' +#!/bin/bash +echo "shell-colors $*" >> "$CALLS" +cat "$S/colors" 2>/dev/null || echo "nothing to do (QtQuick 6.11 already where needed)" +EOF +# sleep: counts; $S/after-N runs (a script) after the Nth one. 300: a loop +# that never ends, stopped. cat > "$T/bin/sleep" <<'EOF' #!/bin/bash echo "sleep $*" >> "$CALLS" n=$(( $(cat "$S/sleeps" 2>/dev/null || echo 0) + 1 )); echo $n > "$S/sleeps" [[ -f $S/after-$n ]] && bash "$S/after-$n" +(( n < 300 )) || kill -9 $PPID exit 0 EOF chmod +x "$T/bin/"* -export PATH=$T/bin:$PATH CALLS=$T/calls S=$T/s +export PATH=$T/bin:$PATH CALLS=$T/calls S=$T/s HOME=$T/home +unset XDG_STATE_HOME +ST=$HOME/.local/state/omacvm +FLAG=$ST/restart-shell # Omarchy's lock status as omarchy-mac's shell answers it (JSON.stringify). status() { # LOCKED REQUESTED SESSIONLOCKED SECURE printf '{"locked":%s,"requested":%s,"pending":false,"sessionLocked":%s,"secure":%s,"realScreens":1,"passwordPam":true,"fingerprint":false,"authenticating":false,"lastEvent":"unlocked","lastEventAt":"2026-10-29T04:33:00.914Z"}\n' "$@" } WARN=$'\e[33m WARN\e[0m: Detected locale "C" with character encoding "ANSI_X3.4-1968", which is not UTF-8.\nQt depends on a UTF-8 locale, and has switched to "C.UTF-8" instead.' -fresh() { rm -rf "$S" "$CALLS"; mkdir -p "$S"; : > "$CALLS"; touch "$S/up"; } -calls() { grep -v '^omarchy-shell' "$CALLS" | paste -sd'|' -; } -SELF=$(cd "$(dirname "$K")" && pwd)/$(basename "$K") +# A shell that runs and answers, with a restart owed (a changed widget). +fresh() { rm -rf "$S" "$CALLS" "$HOME"; mkdir -p "$S" "$ST"; : > "$CALLS"; touch "$S/up" "$FLAG"; echo 100 > "$S/proc"; } +calls() { grep -v -e '^omarchy-shell' -e '^systemctl --user is-active' "$CALLS" | paste -sd'|' -; } +has() { [[ -e $1 ]] && echo yes || echo no; } +SELF=$K QUEUED="systemd-run --user --quiet --collect --unit=omacvm-restart-shell bash $SELF --after-unlock" +COLORS="shell-colors $HOME/.config/omarchy/plugins" +NOW="omacvm-plugins|omarchy-restart-shell" +SAYS="the Omarchy shell gets the new bar widgets right after the next unlock" # 1. Unlocked after an earlier lock: isLocked still says true (Quickshell 0.3.2). fresh; status true false false false > "$S/status"; echo true > "$S/islocked" out=$(bash "$K"); rc=$? -expect "unlocked after an unlock (isLocked stale): restarted now" "omarchy-restart-shell 0" "$(calls) $rc" +expect "unlocked after an unlock (isLocked stale): restarted now" "$NOW 0 no" "$(calls) $rc $(has "$FLAG")" expect " nothing said" "" "$out" # 2. Never locked. fresh; status false false false false > "$S/status"; echo false > "$S/islocked" bash "$K" >/dev/null -expect "never locked: restarted now" "omarchy-restart-shell" "$(calls)" +expect "never locked: restarted now" "$NOW" "$(calls)" -# 3. The lock screen is up: a user unit restarts the shell after the unlock. +# 3. The lock screen is up: a user unit does it after the unlock. fresh; status true true false true > "$S/status"; echo true > "$S/islocked" out=$(bash "$K"); rc=$? -expect "locked: not now, after the unlock" "$QUEUED 0" "$(calls) $rc" -expect " says so" "the Omarchy shell restarts after the next unlock (new bar widgets)" "$out" +expect "locked: not now, after the unlock" "$QUEUED 0 yes" "$(calls) $rc $(has "$FLAG")" +expect " says so" "$SAYS" "$out" # 4. On its way (asked, the compositor not yet locked) and locked without the # request (another path): both wait too. @@ -84,24 +144,25 @@ fresh; status true false true false > "$S/status" bash "$K" >/dev/null expect "session locked: waits" "$QUEUED" "$(calls)" -# 5. The waiter: locked for two looks, then unlocked: restarts once, 2 s later. +# 5. The waiter: locked for two looks, then unlocked: everything owed, then +# one restart, 2 s after the unlock. fresh; status true true true true > "$S/status" status true false false false > "$S/unlocked" echo "cp '$S/unlocked' '$S/status'" > "$S/after-2" bash "$K" --after-unlock >/dev/null; rc=$? -expect "waiter: restarts right after the unlock" "sleep 5|sleep 5|sleep 2|omarchy-restart-shell 0" "$(calls) $rc" +expect "waiter: right after the unlock" "sleep 5|sleep 5|sleep 2|$NOW 0 no" "$(calls) $rc $(has "$FLAG")" -# 6. The waiter while the shell goes away (logout, or a restart by someone -# else: the next shell loads the widgets anyway): ends without a restart. +# 6. The waiter while the shell goes away (logout, shutdown): ends without a +# restart once it is gone twice in a row; the note stays for the next login. fresh; status true true true true > "$S/status" -echo "rm -f '$S/up'" > "$S/after-1" +echo "rm -f '$S/up' '$S/proc'" > "$S/after-1" bash "$K" --after-unlock >/dev/null; rc=$? -expect "waiter: the shell went away: no restart" "sleep 5 0" "$(calls) $rc" +expect "waiter: the shell went away: no restart" "sleep 5|sleep 5 0 yes" "$(calls) $rc $(has "$FLAG")" -# 7. Not running at all (nobody logged in): nothing. -fresh; rm -f "$S/up"; status true false false false > "$S/status" +# 7. Not running at all (nobody logged in): nothing, the note goes. +fresh; rm -f "$S/up" "$S/proc"; status true false false false > "$S/status" out=$(bash "$K"); rc=$? -expect "no shell: nothing" "||0" "$(calls)|$out|$rc" +expect "no shell: nothing" "||0 no" "$(calls)|$out|$rc $(has "$FLAG")" # 8. An Omarchy without the status call: isLocked as before. fresh; echo true > "$S/islocked" @@ -109,12 +170,12 @@ bash "$K" >/dev/null expect "no status call, isLocked true: waits" "$QUEUED" "$(calls)" fresh; echo false > "$S/islocked" bash "$K" >/dev/null -expect "no status call, isLocked false: restarted" "omarchy-restart-shell" "$(calls)" +expect "no status call, isLocked false: restarted" "$NOW" "$(calls)" # 9. A C locale: qs prints Qt's warning before each answer. fresh; { echo "$WARN"; status true false false false; } > "$S/status"; { echo "$WARN"; echo true; } > "$S/islocked" bash "$K" >/dev/null -expect "C locale, unlocked: restarted" "omarchy-restart-shell" "$(calls)" +expect "C locale, unlocked: restarted" "$NOW" "$(calls)" fresh; { echo "$WARN"; status true true true true; } > "$S/status"; { echo "$WARN"; echo true; } > "$S/islocked" bash "$K" >/dev/null expect "C locale, locked: waits" "$QUEUED" "$(calls)" @@ -122,10 +183,95 @@ fresh; { echo "$WARN"; echo true; } > "$S/islocked" bash "$K" >/dev/null expect "C locale, no status call, isLocked true: waits" "$QUEUED" "$(calls)" -# 10. install.sh runs this script, and nothing of OmacVM's asks isLocked -# first any more. +# 10. Nothing owed (no widget changed, nothing staged or queued): nothing at +# all, locked or not; no job waits for an unlock. +fresh; rm -f "$FLAG"; status false false false false > "$S/status" +bash "$K" >/dev/null +expect "nothing owed, unlocked: nothing" "" "$(calls)" +fresh; rm -f "$FLAG"; status true true true true > "$S/status" +bash "$K" >/dev/null +expect "nothing owed, locked: no waiter" "" "$(calls)" + +# 11. Only a staged copy (a new widget: no restart owed by itself). +fresh; rm -f "$FLAG"; mkdir -p "$ST/plugins-staged/omacvm.x"; status true true true true > "$S/status" +bash "$K" >/dev/null +expect "a staged copy, locked: waits" "$QUEUED" "$(calls)" +fresh; rm -f "$FLAG"; mkdir -p "$ST/plugins-staged/omacvm.x"; status false false false false > "$S/status" +bash "$K" >/dev/null +expect "a staged new widget, unlocked: in place, no restart" "omacvm-plugins" "$(calls)" +fresh; rm -f "$FLAG"; mkdir -p "$ST/plugins-staged/omacvm.x"; touch "$S/replaces"; status false false false false > "$S/status" +bash "$K" >/dev/null +expect "a staged copy over a loaded one, unlocked: in place, one restart" "$NOW" "$(calls)" + +# 12. The colour fix on the user's clones before the restart. +fresh; rm -f "$FLAG"; mkdir -p "$ST/plugins-staged/omacvm.x" "$HOME/.config/omarchy/plugins" +echo "patched 1 file (QtQuick 6.11)" > "$S/colors"; status false false false false > "$S/status" +bash "$K" >/dev/null +expect "the clones' colours patched: then one restart" "omacvm-plugins|$COLORS|omarchy-restart-shell" "$(calls)" + +# 13. Busy (the shell runs, does not answer: a reload, a restart) is not gone: +# the waiter waits it out; started then too. +fresh; status true false false false > "$S/status"; rm -f "$S/up" +echo "touch '$S/up'" > "$S/after-2" +bash "$K" --after-unlock >/dev/null +expect "waiter: busy twice, then open: restarted" "sleep 5|sleep 5|sleep 2|$NOW" "$(calls)" +fresh; rm -f "$S/up" +bash "$K" >/dev/null +expect "busy at the end of an apply: waits" "$QUEUED" "$(calls)" + +# 14. A waiter already there (it reads the files when the session opens). +fresh; status true true true true > "$S/status"; touch "$S/active" +out=$(bash "$K") +expect "a waiter already there: no second one" "|$SAYS (already waiting)" "$(calls)|$out" + +# 15. Omarchy refuses the restart (the session locked again): the waiter +# waits again, then restarts once. +fresh; status true false false false > "$S/status"; touch "$S/refuse" +bash "$K" --after-unlock >/dev/null +expect "restart refused: waits, then once" "sleep 2|$NOW|sleep 5|sleep 2|$NOW" "$(calls)" +expect " the note gone after it" "no" "$(has "$FLAG")" + +# 16. Omanotch switched off under the lock (guest/off.sh kept its panel tool): +# its own uninstall runs in the session right after the unlock, before the +# restart. Not while Omanotch is on (its notchcast is being built again). +unset WAYLAND_DISPLAY HYPRLAND_INSTANCE_SIGNATURE +export XDG_RUNTIME_DIR=$T/run +notch_off() { rm -f "$FLAG"; mkdir -p "$HOME/.local/bin" "$XDG_RUNTIME_DIR/hypr/sig1"; touch "$HOME/.local/bin/omanotch-display-panel"; printf 'OMACVM_FEATURE_omanotch=on\nOMACVM_FEATURE_omanotch=%s\n' "$1" > "$T/env"; } +fresh; notch_off off; status true true true true > "$S/status" +bash "$K" >/dev/null +expect "Omanotch off under the lock: waits for the unlock" "$QUEUED" "$(calls)" +fresh; notch_off off; status false false false false > "$S/status" +bash "$K" >/dev/null +expect " unlocked: its uninstall in the session, the panel tool gone" "omacvm-plugins|omanotch-uninstall wayland-1 sig1 no" "$(calls) $(has "$HOME/.local/bin/omanotch-display-panel")" +fresh; notch_off on; status false false false false > "$S/status" +bash "$K" >/dev/null +expect "Omanotch on (rebuilding): nothing owed" "" "$(calls)" +rm -f "$T/env"; unset XDG_RUNTIME_DIR + +# 17. --retire (install.sh's start): a waiter of an earlier apply stops at once +# while the session answers "locked", never while it restarts the shell. +fresh; status true true true true > "$S/status" +bash "$K" --retire; rc=$? +expect "retire, no waiter: nothing" "0 " "$rc $(calls)" +fresh; status true true true true > "$S/status"; touch "$S/active" +bash "$K" --retire +expect "retire, locked: stopped at once" "systemctl --user stop omacvm-restart-shell.service" "$(calls)" +fresh; status true false false false > "$S/status"; touch "$S/active"; rm -f "$S/up" +printf 'touch "%s/up"\n' "$S" > "$S/after-2"; printf 'rm -f "%s/active"\n' "$S" > "$S/after-3" +bash "$K" --retire +expect "retire, in its restart (busy, then open): never stopped" "sleep 1|sleep 1|sleep 1" "$(calls)" +fresh; status true false false false > "$S/status"; touch "$S/active" +bash "$K" --retire +expect "retire, a waiter that does not end: stopped after 90 tries" "90 systemctl --user stop omacvm-restart-shell.service" \ + "$(grep -c '^sleep 1' "$CALLS") $(grep -v -e '^sleep' "$CALLS" | grep -v -e '^omarchy-shell' -e 'is-active')" + +# 18. install.sh retires an earlier waiter at its start, runs this script at +# the end when a shell runs and drops the note when none does; nothing +# of OmacVM's asks isLocked first any more. I=$R/src/guest/install.sh -expect "install.sh runs restart-shell.sh" 1 "$(grep -c 'bash "$R/guest/restart-shell.sh"' "$I")" +expect "install.sh retires an earlier waiter" 1 "$(grep -c 'user_script "$R/guest/restart-shell.sh" --retire' "$I")" +expect "install.sh runs restart-shell.sh when a shell runs" 1 "$(grep -c '^ user_script "$R/guest/restart-shell.sh" || true$' "$I")" +expect "install.sh drops the note without a shell" 1 "$(grep -c '^ home_run rm -f "$RS"' "$I")" expect "no isLocked in install.sh" 0 "$(grep -c 'lock isLocked' "$I")" expect "copies.py asks status first" 1 "$(grep -c '"omarchy-shell", "lock", "status"' "$R/src/omanotch/guest/copies.py")"