From d07e4bfc21cf7486cb7af01bc104717049168073 Mon Sep 17 00:00:00 2001 From: brkosebay Date: Sun, 8 Nov 2026 16:00:00 +0100 Subject: [PATCH 1/7] Keep rotation layers mapped and touch on the panel Assisted-by: LLM --- desktop/configuration/rotate-output | 14 ++++-- desktop/shell/bin/omarchy-rotate | 24 ++++++++++ desktop/shell/bin/omarchy-rotation-lock | 12 +++++ .../shell/default/omarchy/omarchy-menu.jsonc | 3 ++ desktop/shell/labwc-rc.xml | 1 + desktop/shell/shell/Ui/ScreenMoveRemap.qml | 44 +++---------------- release/public_root.py | 14 +++++- tests/test_rotate_output.py | 18 ++++++++ tests/test_tablet_port.py | 40 +++++++++++++++++ 9 files changed, 127 insertions(+), 43 deletions(-) create mode 100755 desktop/shell/bin/omarchy-rotate create mode 100755 desktop/shell/bin/omarchy-rotation-lock create mode 100644 tests/test_tablet_port.py diff --git a/desktop/configuration/rotate-output b/desktop/configuration/rotate-output index 82083d2..39661ac 100644 --- a/desktop/configuration/rotate-output +++ b/desktop/configuration/rotate-output @@ -23,7 +23,7 @@ transform() { apply() { local t output - [[ -e $lock ]] && return + [[ -e $lock ]] && return 1 t=$(transform "$1") || return output=$(wlr-randr | awk '/^[^[:space:]]/ {print $1; exit}') [[ -n $output ]] && wlr-randr --output "$output" --transform "$t" @@ -36,7 +36,7 @@ orientation() { esac } -exec {sensor}< <(exec monitor-sensor --accel) +exec {sensor}< <(exec stdbuf -oL monitor-sensor --accel) sensor_pid=$! watcher='' if [[ -n ${LABWC_PID:-} ]]; then @@ -55,8 +55,14 @@ while :; do continue fi ((status > 128)) || break # end of input, not a timeout - [[ $pending != "$applied" ]] && apply "$pending" && applied=$pending - pending='' + [[ -e $lock ]] && applied='' + if [[ $pending != "$applied" ]]; then + # Keep the latest settled pose while locked. Retrying the read timeout + # reapplies it after unlock even if the sensor sends no fresh event. + apply "$pending" || continue + applied=$pending + fi + # Keep the last pose to restore auto orientation after a manual lock. else IFS= read -r -u "$sensor" line || break next=$(orientation "$line") && [[ -n $next ]] && pending=$next diff --git a/desktop/shell/bin/omarchy-rotate b/desktop/shell/bin/omarchy-rotate new file mode 100755 index 0000000..512d377 --- /dev/null +++ b/desktop/shell/bin/omarchy-rotate @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Manual rotation uses the same output protocol as auto-rotation; holds auto until unlock.""" +import json, os, subprocess, sys +from pathlib import Path +mode = sys.argv[1] if len(sys.argv)>1 else 'status' +outputs = json.loads(subprocess.check_output(['wlr-randr', '--json'], text=True)) +panel = next((o for o in outputs if o['name'] == 'eDP-1'), None) +if panel is None: + panel = next(o for o in outputs if o.get('enabled')) +current = panel['transform'] +turns = ['normal', '90', '180', '270'] +if mode == 'status': + print(current); raise SystemExit(0) +if mode == 'next': mode = turns[(turns.index(current)+1)%4] +mode = {'portrait':'normal','landscape':'90','portrait-flipped':'180','landscape-flipped':'270'}.get(mode,mode) +if mode not in turns: raise SystemExit('Usage: omarchy-rotate status|next|normal|90|180|270') +lock = Path(os.environ.get('XDG_CONFIG_HOME', str(Path.home()/'.config')))/'omarchy/rotation-lock' +was_locked = lock.exists() +lock.parent.mkdir(parents=True,exist_ok=True); lock.touch() +try: + subprocess.run(['wlr-randr','--output',panel['name'],'--transform',mode],check=True,timeout=5) +except (OSError, subprocess.SubprocessError): + if not was_locked: lock.unlink(missing_ok=True) + raise diff --git a/desktop/shell/bin/omarchy-rotation-lock b/desktop/shell/bin/omarchy-rotation-lock new file mode 100755 index 0000000..744dc37 --- /dev/null +++ b/desktop/shell/bin/omarchy-rotation-lock @@ -0,0 +1,12 @@ +#!/bin/bash +# Share the orientation lock with arch-ipad-rotate-output. +set -euo pipefail +lock=${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/rotation-lock +case ${1:-toggle} in + enabled) [[ -e $lock ]]; exit ;; + status) if [[ -e $lock ]]; then echo locked; else echo automatic; fi ;; + on) mkdir -p "$(dirname "$lock")"; touch "$lock" ;; + off) rm -f -- "$lock" ;; + toggle) if [[ -e $lock ]]; then rm -f -- "$lock"; else mkdir -p "$(dirname "$lock")"; touch "$lock"; fi ;; + *) echo 'Usage: omarchy-rotation-lock [on|off|toggle|enabled|status]' >&2; exit 2 ;; +esac diff --git a/desktop/shell/default/omarchy/omarchy-menu.jsonc b/desktop/shell/default/omarchy/omarchy-menu.jsonc index b337560..8cb5c0d 100644 --- a/desktop/shell/default/omarchy/omarchy-menu.jsonc +++ b/desktop/shell/default/omarchy/omarchy-menu.jsonc @@ -181,6 +181,8 @@ "trigger.tests.disk-speedtest": {"icon":"󰋊","label":"Disk Speed Test","action":"omarchy-shell shell summon omarchy.disk-speedtest"}, // Setup + "setup.tablet": {"icon":"󰦧","label":"Tablet"}, + "setup.tablet.rotation-lock": {"icon":"󰿆","label":"Rotation Lock","checked":"omarchy-rotation-lock enabled","action":"omarchy-rotation-lock toggle"}, "setup.monitors": {"icon":"󰍹","label":"Monitors","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/monitors.lua\""}, "setup.keybindings": {"icon":"","label":"Keybindings","when":"[[ -f ~/.config/hypr/bindings.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/bindings.lua\""}, "setup.input": {"icon":"","label":"Input","when":"[[ -f ~/.config/hypr/input.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/input.lua\""}, @@ -450,4 +452,5 @@ "update.hardware.trackpad": {"icon":"󰟸","label":"Trackpad","action":"omarchy-launch-floating-terminal-with-presentation omarchy-restart-trackpad"}, "update.password.drive": {"icon":"","label":"Drive Encryption","action":"omarchy-launch-floating-terminal-with-presentation omarchy-drive-password"}, "update.password.user": {"icon":"","label":"User","action":"omarchy-launch-floating-terminal-with-presentation passwd"}, + "setup.tablet.rotate": {"icon": "󰑢", "label": "Rotate Once and Lock", "action": "omarchy-rotate next"}, } diff --git a/desktop/shell/labwc-rc.xml b/desktop/shell/labwc-rc.xml index 6fea059..7ec8313 100644 --- a/desktop/shell/labwc-rc.xml +++ b/desktop/shell/labwc-rc.xml @@ -117,6 +117,7 @@ + no diff --git a/desktop/shell/shell/Ui/ScreenMoveRemap.qml b/desktop/shell/shell/Ui/ScreenMoveRemap.qml index 44d2308..7c9d24d 100644 --- a/desktop/shell/shell/Ui/ScreenMoveRemap.qml +++ b/desktop/shell/shell/Ui/ScreenMoveRemap.qml @@ -1,43 +1,13 @@ import QtQuick -// Hyprland leaves an already-mapped layer surface at its old global position -// when its monitor moves within the layout: undocking disables the internal -// panel, the external monitor shifts to x=0, and long-lived surfaces such as -// the bar and background keep rendering at the old offset — or entirely -// off-screen — until they are unmapped and remapped. Watch the screen's -// origin and pulse `remapping` when it moves; the owning window folds that -// into its `visible` binding so the compositor re-places the surface at the -// monitor's new origin. +// Labwc repositions mapped layer-shell surfaces itself when outputs move or +// resize. Qt emits x/y notifications for a size-only change too. The old +// Hyprland workaround hid every panel and wallpaper 200ms later, destroying +// their layer surfaces and buffers for at least another 50ms. On a software +// renderer recreation/decoding extends that gap. Keep the public component +// interface, but never unmap a labwc layer just because its output changed. Item { - id: root - required property var window - readonly property var screen: window ? window.screen : null - - // Fold into the window's binding: visible: && !guard.remapping - property bool remapping: false - + readonly property bool remapping: false visible: false - - // A layout reshuffle can move the monitor more than once before it lands. - // Let the positions settle before the single remap pulse. - Timer { - id: settleTimer - interval: 200 - onTriggered: root.remapping = true - } - - // Hold the surface unmapped for a beat so the compositor processes the - // unmap before the remap instead of coalescing them into a no-op. - Timer { - interval: 50 - running: root.remapping - onTriggered: root.remapping = false - } - - Connections { - target: root.screen - function onXChanged() { settleTimer.restart() } - function onYChanged() { settleTimer.restart() } - } } diff --git a/release/public_root.py b/release/public_root.py index 2df5ef0..76e3f6c 100644 --- a/release/public_root.py +++ b/release/public_root.py @@ -39,6 +39,10 @@ def desktop_replacements(): 'bin/omarchy-bluetooth-device')} replacements['home/tablet/.config/labwc/rc.xml'] = (PROJECT / 'desktop/shell/labwc-rc.xml').read_bytes() replacements['home/tablet/.config/labwc/autostart'] = (PROJECT / 'desktop/configuration/labwc-autostart').read_bytes() + replacements[prefix + 'shell/Ui/ScreenMoveRemap.qml'] = (PROJECT / 'desktop/shell/shell/Ui/ScreenMoveRemap.qml').read_bytes() + menu = (PROJECT / 'desktop/shell/default/omarchy/omarchy-menu.jsonc').read_text() + replacements[prefix + 'default/omarchy/omarchy-menu.jsonc'] = menu.encode() + replacements['usr/local/libexec/arch-ipad-rotate-output'] = (PROJECT / 'desktop/configuration/rotate-output').read_bytes() return replacements @@ -121,8 +125,14 @@ def labwc_files(directory, architecture='aarch64'): def desktop_additions(): - name = 'home/tablet/.local/share/omarchy-ipad/shell/bluetooth-pairing/shell.qml' - return {name: ((PROJECT / 'desktop/shell/shell/bluetooth-pairing/shell.qml').read_bytes(), 0o644)} + prefix = 'home/tablet/.local/share/omarchy-ipad/' + files = {prefix + 'shell/bluetooth-pairing/shell.qml': ((PROJECT / 'desktop/shell/shell/bluetooth-pairing/shell.qml').read_bytes(), 0o644)} + files['usr/local/libexec/arch-ipad-touch-output'] = ((PROJECT / 'desktop/configuration/touch-output').read_bytes(), 0o755) + files[prefix + 'bin/omarchy-rotation-lock'] = ((PROJECT / 'desktop/shell/bin/omarchy-rotation-lock').read_bytes(), 0o755) + files['usr/local/bin/omarchy-rotation-lock'] = files[prefix + 'bin/omarchy-rotation-lock'] + files[prefix + 'bin/omarchy-rotate'] = ((PROJECT / 'desktop/shell/bin/omarchy-rotate').read_bytes(), 0o755) + files['usr/local/bin/omarchy-rotate'] = files[prefix + 'bin/omarchy-rotate'] + return files def system_files(): diff --git a/tests/test_rotate_output.py b/tests/test_rotate_output.py index d97c256..bdd3489 100644 --- a/tests/test_rotate_output.py +++ b/tests/test_rotate_output.py @@ -87,6 +87,24 @@ def test_lock_holds_the_orientation(self): self.assertEqual(self.run_script(events, locked=True), []) + def test_unlock_applies_last_settled_pose_without_another_sensor_event(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); bindir = root/'bin'; bindir.mkdir() + lock = root/'config/omarchy/rotation-lock'; lock.parent.mkdir(parents=True); lock.touch() + log = root/'randr.log' + for name, body in (('wlr-randr', RANDR), ('monitor-sensor', + monitor([(0, '=== Has accelerometer (orientation: left-up)'), (0.8, 'waiting')]))): + f=bindir/name; f.write_text(body); f.chmod(0o755) + env={**os.environ,'PATH':str(bindir)+':'+os.environ['PATH'], + 'XDG_CONFIG_HOME':str(root/'config'),'RANDR_LOG':str(log),'ROTATE_SETTLE':'0.1'} + proc=subprocess.Popen([BASH,str(SCRIPT)],env=env) + try: + threading.Event().wait(0.35); self.assertFalse(log.exists()); lock.unlink() + proc.wait(timeout=3) + self.assertEqual(log.read_text().splitlines(),['--output Unknown-1 --transform 90']) + finally: + if proc.poll() is None: proc.kill(); proc.wait() + def test_ends_with_the_compositor(self): # monitor-sensor never ends by itself; the helper must stop it once # the compositor is gone instead of outliving the session diff --git a/tests/test_tablet_port.py b/tests/test_tablet_port.py new file mode 100644 index 0000000..269e16c --- /dev/null +++ b/tests/test_tablet_port.py @@ -0,0 +1,40 @@ +import importlib.machinery +import importlib.util +import json +import os +from pathlib import Path +import struct +import subprocess +import unittest +from unittest.mock import patch +import xml.etree.ElementTree as ET + +PROJECT = Path(__file__).resolve().parents[1] + +def load(name, path): + loader=importlib.machinery.SourceFileLoader(name,str(PROJECT/path)) + spec=importlib.util.spec_from_loader(name,loader) + mod=importlib.util.module_from_spec(spec); loader.exec_module(mod); return mod + +p=load('power','system/overlay/usr/libexec/omarchy-powerd') + +class TabletPort(unittest.TestCase): + + + + + + + + def test_no_false_client_remap_and_native_touch(self): + source=(PROJECT/'desktop/shell/shell/Ui/ScreenMoveRemap.qml').read_text() + self.assertIn('readonly property bool remapping: false',source) + self.assertNotIn('Timer {',source) + tree=ET.fromstring((PROJECT/'desktop/shell/labwc-rc.xml').read_text()) + touch=next(t for t in tree.findall('touch') if t.get('deviceName')=='iPad6,11 Touchscreen') + self.assertEqual(touch.get('mapToOutput'),'eDP-1') + self.assertEqual(touch.get('mouseEmulation'),'no') + + + +if __name__=='__main__': unittest.main() From e83a6287f4f4a73d8bf32aafadaefb2d0ae13da9 Mon Sep 17 00:00:00 2001 From: brkosebay Date: Sun, 8 Nov 2026 16:00:00 +0100 Subject: [PATCH 2/7] Power, idle dim and native logout Assisted-by: LLM --- desktop/shell/bin/omarchy-system | 10 ++ .../shell/plugins/services/idle/Service.qml | 13 +++ release/public_root.py | 11 ++- .../logind.conf.d/60-omarchy-a-power.conf | 4 + .../90-omarchy-a-floor.conf | 2 + .../usr/libexec/omarchy-backlight-floor | 8 ++ system/overlay/usr/libexec/omarchy-powerd | 92 ++++++++++++++++++- tests/test_menu_shutdown.py | 2 +- tests/test_powerd.py | 36 +++++++- tests/test_tablet_port.py | 41 +++++++++ 10 files changed, 212 insertions(+), 7 deletions(-) create mode 100755 desktop/shell/bin/omarchy-system create mode 100644 system/overlay/etc/systemd/system/systemd-backlight@.service.d/90-omarchy-a-floor.conf create mode 100755 system/overlay/usr/libexec/omarchy-backlight-floor create mode 100644 tests/test_tablet_port.py diff --git a/desktop/shell/bin/omarchy-system b/desktop/shell/bin/omarchy-system new file mode 100755 index 0000000..51f5b0f --- /dev/null +++ b/desktop/shell/bin/omarchy-system @@ -0,0 +1,10 @@ +#!/bin/bash +set -euo pipefail +case ${1:-} in + logout) exec labwc --exit ;; + lock) exec omarchy-shell lock lock ;; + suspend) ipadlinux-suspend-ready && exec systemctl suspend ;; + reboot) exec systemctl reboot ;; + shutdown|poweroff) exec systemctl poweroff ;; + *) echo 'Usage: omarchy-system logout|lock|suspend|reboot|shutdown' >&2; exit 2 ;; +esac diff --git a/desktop/shell/shell/plugins/services/idle/Service.qml b/desktop/shell/shell/plugins/services/idle/Service.qml index 9922e2b..f16e4f2 100644 --- a/desktop/shell/shell/plugins/services/idle/Service.qml +++ b/desktop/shell/shell/plugins/services/idle/Service.qml @@ -281,6 +281,19 @@ Item { onTriggered: if (root.screensaverEnabled) root.launchScreensaver() } + // Dim before the 5-minute lock. The power daemon serializes dim/ALS/off; + // input restores only if a manual brightness change has not superseded dim. + IdleMonitor { + id: dimMonitor + timeout: 180 + enabled: Quickshell.env("OMARCHY_TOUCH_MODE") === "1" && root.idleEnabled && root.lockEnabled + onIsIdleChanged: { + dimProcess.command = ["/usr/libexec/omarchy-powerctl", isIdle ? "dim" : "undim"] + dimProcess.running = true + } + } + Process { id: dimProcess } + Timer { id: lockTimer repeat: false diff --git a/release/public_root.py b/release/public_root.py index 2df5ef0..d4626fe 100644 --- a/release/public_root.py +++ b/release/public_root.py @@ -39,6 +39,10 @@ def desktop_replacements(): 'bin/omarchy-bluetooth-device')} replacements['home/tablet/.config/labwc/rc.xml'] = (PROJECT / 'desktop/shell/labwc-rc.xml').read_bytes() replacements['home/tablet/.config/labwc/autostart'] = (PROJECT / 'desktop/configuration/labwc-autostart').read_bytes() + replacements[prefix + 'shell/plugins/services/idle/Service.qml'] = (PROJECT / 'desktop/shell/shell/plugins/services/idle/Service.qml').read_bytes() + menu = (PROJECT / 'desktop/shell/default/omarchy/omarchy-menu.jsonc').read_text() + menu = menu.replace('! omarchy-toggle-enabled suspend-off && ! omarchy-sleep-disabled', 'command -v ipadlinux-suspend-ready >/dev/null && ipadlinux-suspend-ready') + replacements[prefix + 'default/omarchy/omarchy-menu.jsonc'] = menu.encode() return replacements @@ -121,8 +125,11 @@ def labwc_files(directory, architecture='aarch64'): def desktop_additions(): - name = 'home/tablet/.local/share/omarchy-ipad/shell/bluetooth-pairing/shell.qml' - return {name: ((PROJECT / 'desktop/shell/shell/bluetooth-pairing/shell.qml').read_bytes(), 0o644)} + prefix = 'home/tablet/.local/share/omarchy-ipad/' + files = {prefix + 'shell/bluetooth-pairing/shell.qml': ((PROJECT / 'desktop/shell/shell/bluetooth-pairing/shell.qml').read_bytes(), 0o644)} + files[prefix + 'bin/omarchy-system'] = ((PROJECT / 'desktop/shell/bin/omarchy-system').read_bytes(), 0o755) + files['usr/local/bin/omarchy-system'] = files[prefix + 'bin/omarchy-system'] + return files def system_files(): diff --git a/system/overlay/etc/systemd/logind.conf.d/60-omarchy-a-power.conf b/system/overlay/etc/systemd/logind.conf.d/60-omarchy-a-power.conf index 4c3908a..61a2812 100644 --- a/system/overlay/etc/systemd/logind.conf.d/60-omarchy-a-power.conf +++ b/system/overlay/etc/systemd/logind.conf.d/60-omarchy-a-power.conf @@ -3,3 +3,7 @@ HandlePowerKey=ignore HandleSuspendKey=ignore HandleHibernateKey=ignore IdleAction=ignore + +HandleLidSwitch=ignore +HandleLidSwitchExternalPower=ignore +HandleLidSwitchDocked=ignore diff --git a/system/overlay/etc/systemd/system/systemd-backlight@.service.d/90-omarchy-a-floor.conf b/system/overlay/etc/systemd/system/systemd-backlight@.service.d/90-omarchy-a-floor.conf new file mode 100644 index 0000000..33045b3 --- /dev/null +++ b/system/overlay/etc/systemd/system/systemd-backlight@.service.d/90-omarchy-a-floor.conf @@ -0,0 +1,2 @@ +[Service] +ExecStartPost=/usr/libexec/omarchy-backlight-floor diff --git a/system/overlay/usr/libexec/omarchy-backlight-floor b/system/overlay/usr/libexec/omarchy-backlight-floor new file mode 100755 index 0000000..bf96173 --- /dev/null +++ b/system/overlay/usr/libexec/omarchy-backlight-floor @@ -0,0 +1,8 @@ +#!/usr/bin/python3 +"""Recover a dim/off saved backlight on boot, after systemd restores it.""" +from pathlib import Path +for panel in Path('/sys/class/backlight').glob('*'): + maximum = int((panel/'max_brightness').read_text()) + current = int((panel/'brightness').read_text()) + if current * 100 < maximum * 15: + (panel/'brightness').write_text(str(max(1,maximum*40//100))+'\n') diff --git a/system/overlay/usr/libexec/omarchy-powerd b/system/overlay/usr/libexec/omarchy-powerd index a6373f3..da6f2f0 100755 --- a/system/overlay/usr/libexec/omarchy-powerd +++ b/system/overlay/usr/libexec/omarchy-powerd @@ -250,6 +250,34 @@ def key_presses(data): return presses +class KeyFilter: + def __init__(self): + self.power = self.home = self.cancelled = False + + def events(self, data): + out = [] + for off in range(0, len(data)-INPUT_EVENT.size+1, INPUT_EVENT.size): + sec,usec,kind,code,value = INPUT_EVENT.unpack_from(data, off) + if kind != EV_KEY or value not in (0,1): continue + when = sec + usec/1000000 + if code == KEY_HOMEPAGE: + self.home = bool(value) + if value and self.power: self.cancelled = True + elif value: out.append(('home',when)) + elif code == KEY_POWER: + if value: + self.power, self.cancelled = True, self.home + elif self.power: + self.power = False + if not self.cancelled and not self.home: out.append(('power',when)) + return out + + +def platform_suspend_ready(): + helper = '/usr/local/bin/ipadlinux-suspend-ready' + return Path(helper).exists() and command(helper).returncode == 0 + + def command(*argv, timeout=5, **options): return subprocess.run(argv, capture_output=True, text=True, timeout=timeout, **options) @@ -287,8 +315,11 @@ class Daemon: self.saved = None self.counters = None self.sleep_started = None + self.key_filter = KeyFilter() self.ticks = 0 self.cancel = threading.Event() + self.dim_saved = None + self.dim_level = None self.dark = threading.Event() self.session_jobs = queue.Queue() self.inhibitor = None @@ -405,12 +436,21 @@ class Daemon: getattr(self, 'do_' + action.replace('-', '_'))(before) self.dark.set() if self.machine.state in (SETTLING, DARK) else self.dark.clear() + def undim(self): + panel = self.backlight() + if self.dim_saved is not None and panel: + current = int((panel / 'brightness').read_text()) + if self.machine.state == ON and current == self.dim_level: + (panel / 'brightness').write_text(f'{self.dim_saved}\n') + self.dim_saved = self.dim_level = None + def do_dark(self, _): panel = self.backlight() if panel: current = int((panel / 'brightness').read_text()) if current > 0: - self.saved = current + self.saved = self.dim_saved or current + self.dim_saved = self.dim_level = None (panel / 'brightness').write_text('0\n') def do_light(self, origin): @@ -481,6 +521,10 @@ class Daemon: def request_suspend(self): try: + if not platform_suspend_ready(): + log('no suspend: platform readiness contract refused') + self.post('suspend-refused') + return answer = command('busctl', 'call', 'org.freedesktop.login1', '/org/freedesktop/login1', 'org.freedesktop.login1.Manager', 'CanSuspend').stdout.strip() if answer != 's "yes"': @@ -503,6 +547,8 @@ class Daemon: try: user = session_user() if job == 'lock': + if user is not None: + as_user(user, 'omarchy-keyboard', 'hide') self.post('lock-ok' if user is None or self.lock_session(user) else 'lock-failed') elif user is not None and job == 'woke': as_user(user, 'omarchy-shell', '-q', 'lock', 'woke') @@ -552,6 +598,48 @@ class Daemon: connection.sendall(b'lit\n' if self.machine.state == ON else b'dark\n') elif request == 'status': connection.sendall(self.machine.state.encode() + b'\n') + elif request.startswith('brightness '): + parts = request.split() + panel = self.backlight() + reply = b'held\n' + if (len(parts) == 4 and panel and parts[1] == panel.name and + parts[2].isascii() and parts[2].isdigit() and + parts[3].isascii() and parts[3].isdigit()): + level, expected = int(parts[2]), int(parts[3]) + current = int((panel / 'brightness').read_text()) + maximum = int((panel / 'max_brightness').read_text()) + if self.machine.state == ON and self.dim_saved is None and current > 0 and current == expected and 1 <= level <= maximum: + (panel / 'brightness').write_text(f'{level}\n') + reply = b'ok\n' + connection.sendall(reply) + elif request == 'als-state': + connection.sendall(b'lit\n' if self.machine.state == ON and self.dim_saved is None else b'dark\n') + elif request == 'dim': + panel = self.backlight() + if self.machine.state == ON and self.dim_saved is None and panel: + current = int((panel / 'brightness').read_text()) + level = max(1, int((panel / 'max_brightness').read_text()) // 10) + if current > level: + self.dim_saved, self.dim_level = current, level + (panel / 'brightness').write_text(f'{level}\n') + connection.sendall(b'ok\n') + elif request == 'undim': + self.undim() + connection.sendall(b'ok\n') + elif request == 'cover-suspend': + if self.machine.state == DARK: + self.machine.no_suspend = False + self.feed('not-playing') + connection.sendall(b'ok\n') + elif request == 'cover-close': + self.feed('idle') + # Lock, darken and stay dark without automatic suspend. + # Cover polarity is opt-in; sleep remains separately qualified. + if self.machine.state == SETTLING: + self.settle_at = None + self.machine.state = DARK + self.machine.no_suspend = True + connection.sendall(b'ok\n') elif request in ('idle', 'wake'): self.feed(request) connection.sendall(b'ok\n') @@ -641,7 +729,7 @@ class Daemon: # Keys first: a press that woke the tablet decides before the sleep's end is seen. for fd in [fd for fd in readable if fd in self.keys]: try: - for name, when in key_presses(os.read(fd, INPUT_EVENT.size * 64)): + for name, when in self.key_filter.events(os.read(fd, INPUT_EVENT.size * 64)): # Pressed before this wake's panel lit: that wake took care of it. if self.machine.inflight and self.lit_at is not None and when <= self.lit_at: log(f't={when:.6f} {name}: consumed by the wake') diff --git a/tests/test_menu_shutdown.py b/tests/test_menu_shutdown.py index 329ede7..568be7d 100644 --- a/tests/test_menu_shutdown.py +++ b/tests/test_menu_shutdown.py @@ -36,7 +36,7 @@ def test_shutdown_requests_normal_system_poweroff(self): self.check_system_action('shutdown', 'poweroff') def test_reboot_requests_normal_system_reboot(self): - self.check_system_action('reboot', 'reboot') + self.check_system_action("reboot", "reboot") if __name__ == '__main__': diff --git a/tests/test_powerd.py b/tests/test_powerd.py index 30a4ff9..a4b100f 100644 --- a/tests/test_powerd.py +++ b/tests/test_powerd.py @@ -364,7 +364,7 @@ def setUp(self): self.unit_state = 'activating' for target, value in (('SETTLE_SECONDS', 0.1), ('STATS', self.stats), ('BACKLIGHTS', self.work / 'backlight'), ('WAKEUP_COUNT', self.work / 'wakeup_count'), ('SOCKET', str(self.work / 'ctl')), - ('session_user', lambda: None), ('command', self.fake_command), ('log', lambda *_: None)): + ('platform_suspend_ready', lambda: True), ('session_user', lambda: None), ('command', self.fake_command), ('log', lambda *_: None)): patcher = patch.object(powerd, target, value) patcher.start() self.addCleanup(patcher.stop) @@ -410,7 +410,7 @@ def set_stats(self, success, fail): def press(self, code=116, when=None): when = time.monotonic() if when is None else when - os.write(self.key_write, powerd.INPUT_EVENT.pack(int(when), int(when % 1 * 1e6), 1, code, 1)) + os.write(self.key_write, b''.join(powerd.INPUT_EVENT.pack(int(when), int(when % 1 * 1e6), 1, code, v) for v in (1,0))) def wait(self, condition, seconds=3): deadline = time.monotonic() + seconds @@ -430,6 +430,38 @@ def ask(self, request): connection.sendall(request.encode() + b'\n') return connection.recv(64).decode().strip() + def test_idle_dim_restore_and_manual_brightness_supersedes_dim(self): + self.assertEqual(self.ask('dim'), 'ok') + self.assertEqual(self.brightness(), '300') + self.assertEqual(self.ask('als-state'), 'dark') + self.assertEqual(self.ask('brightness panel 600 300'), 'held') + self.ask('undim'); self.assertEqual(self.brightness(), '1563') + self.ask('dim') + (self.panel / 'brightness').write_text('700\n') + self.ask('undim'); self.assertEqual(self.brightness(), '700') + + def test_cover_close_locks_without_automatic_suspend_and_open_wakes(self): + self.ask('cover-close') + self.wait(lambda: self.daemon.machine.lock_ok) + self.assertEqual(self.ask('status'), 'DARK') + self.assertEqual(self.brightness(), '0') + time.sleep(0.2) + self.assertNotIn(('systemctl', 'suspend'), self.requests) + self.ask('wake'); self.assertEqual(self.brightness(), '1563') + + def test_automatic_brightness_obeys_power_state_and_manual_changes(self): + self.assertEqual(self.ask('brightness panel 1800 1563'), 'ok') + self.assertEqual(self.brightness(), '1800') + self.assertEqual(self.ask('brightness panel 1900 1563'), 'held') + self.assertEqual(self.brightness(), '1800') + for request in ('brightness panel 0 1800', 'brightness panel 3001 1800', + 'brightness other 1900 1800', 'brightness panel ² 1800'): + self.assertEqual(self.ask(request), 'held') + self.assertEqual(self.brightness(), '1800') + self.press(); self.wait(lambda: self.brightness() == '0') + self.assertEqual(self.ask('brightness panel 1800 0'), 'held') + self.assertEqual(self.brightness(), '0') + def test_press_sleep_wake(self): machine = self.daemon.machine self.assertEqual(self.ask('input'), 'lit') diff --git a/tests/test_tablet_port.py b/tests/test_tablet_port.py new file mode 100644 index 0000000..2c0e5c9 --- /dev/null +++ b/tests/test_tablet_port.py @@ -0,0 +1,41 @@ +import importlib.machinery +import importlib.util +import json +import os +from pathlib import Path +import struct +import subprocess +import unittest +from unittest.mock import patch +import xml.etree.ElementTree as ET + +PROJECT = Path(__file__).resolve().parents[1] + +def load(name, path): + loader=importlib.machinery.SourceFileLoader(name,str(PROJECT/path)) + spec=importlib.util.spec_from_loader(name,loader) + mod=importlib.util.module_from_spec(spec); loader.exec_module(mod); return mod + +p=load('power','system/overlay/usr/libexec/omarchy-powerd') + +class TabletPort(unittest.TestCase): + + + + + + def test_home_power_overlap_in_either_order_cancels_power(self): + event=lambda code,v: p.INPUT_EVENT.pack(1,0,1,code,v) + for codes in ([(116,1),(172,1),(172,0),(116,0)],[(172,1),(116,1),(172,0),(116,0)]): + f=p.KeyFilter(); result=[] + for code,v in codes: result+=f.events(event(code,v)) + self.assertNotIn('power',[name for name,when in result]) + f=p.KeyFilter() + self.assertEqual(f.events(event(116,1)),[]) + self.assertEqual(f.events(event(116,0)),[('power',1.0)]) + + + + + +if __name__=='__main__': unittest.main() From 4724b8157938c1379d081649f1f92040982bf9ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20K=C3=B6sebay?= Date: Thu, 12 Nov 2026 09:00:00 +0100 Subject: [PATCH 3/7] Rotation: manual rotation and rotation lock next to automatic rotation Automatic rotation could not be overridden: there was no manual rotation or lock in the menu. Add omarchy-rotate with rotate and lock commands, menu entries for them, and the release root wiring. Assisted-by: LLM --- desktop/shell/bin/omarchy-rotate | 24 +++++++++++++++++++ .../shell/default/omarchy/omarchy-menu.jsonc | 2 ++ release/public_root.py | 13 ++++++---- 3 files changed, 35 insertions(+), 4 deletions(-) create mode 100755 desktop/shell/bin/omarchy-rotate diff --git a/desktop/shell/bin/omarchy-rotate b/desktop/shell/bin/omarchy-rotate new file mode 100755 index 0000000..512d377 --- /dev/null +++ b/desktop/shell/bin/omarchy-rotate @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Manual rotation uses the same output protocol as auto-rotation; holds auto until unlock.""" +import json, os, subprocess, sys +from pathlib import Path +mode = sys.argv[1] if len(sys.argv)>1 else 'status' +outputs = json.loads(subprocess.check_output(['wlr-randr', '--json'], text=True)) +panel = next((o for o in outputs if o['name'] == 'eDP-1'), None) +if panel is None: + panel = next(o for o in outputs if o.get('enabled')) +current = panel['transform'] +turns = ['normal', '90', '180', '270'] +if mode == 'status': + print(current); raise SystemExit(0) +if mode == 'next': mode = turns[(turns.index(current)+1)%4] +mode = {'portrait':'normal','landscape':'90','portrait-flipped':'180','landscape-flipped':'270'}.get(mode,mode) +if mode not in turns: raise SystemExit('Usage: omarchy-rotate status|next|normal|90|180|270') +lock = Path(os.environ.get('XDG_CONFIG_HOME', str(Path.home()/'.config')))/'omarchy/rotation-lock' +was_locked = lock.exists() +lock.parent.mkdir(parents=True,exist_ok=True); lock.touch() +try: + subprocess.run(['wlr-randr','--output',panel['name'],'--transform',mode],check=True,timeout=5) +except (OSError, subprocess.SubprocessError): + if not was_locked: lock.unlink(missing_ok=True) + raise diff --git a/desktop/shell/default/omarchy/omarchy-menu.jsonc b/desktop/shell/default/omarchy/omarchy-menu.jsonc index de7b289..28043c5 100644 --- a/desktop/shell/default/omarchy/omarchy-menu.jsonc +++ b/desktop/shell/default/omarchy/omarchy-menu.jsonc @@ -184,6 +184,8 @@ "trigger.tests.disk-speedtest": {"icon":"󰋊","label":"Disk Speed Test","action":"omarchy-shell shell summon omarchy.disk-speedtest"}, // Setup + "setup.tablet": {"icon":"󰦧","label":"Tablet"}, + "setup.tablet.rotate": {"icon":"󰑢","label":"Rotate Once and Lock","action":"omarchy-rotate next"}, "setup.monitors": {"icon":"󰍹","label":"Monitors","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/monitors.lua\""}, "setup.keybindings": {"icon":"","label":"Keybindings","when":"[[ -f ~/.config/hypr/bindings.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/bindings.lua\""}, "setup.input": {"icon":"","label":"Input","when":"[[ -f ~/.config/hypr/input.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/input.lua\""}, diff --git a/release/public_root.py b/release/public_root.py index 63a2d35..45a909c 100644 --- a/release/public_root.py +++ b/release/public_root.py @@ -42,6 +42,7 @@ def desktop_replacements(): replacements['home/tablet/.config/labwc/rc.xml'] = (PROJECT / 'desktop/shell/labwc-rc.xml').read_bytes() replacements['home/tablet/.config/labwc/autostart'] = (PROJECT / 'desktop/configuration/labwc-autostart').read_bytes() replacements['usr/local/libexec/arch-ipad-rotate-output'] = (PROJECT / 'desktop/configuration/rotate-output').read_bytes() + replacements[prefix + 'default/omarchy/omarchy-menu.jsonc'] = (PROJECT / 'desktop/shell/default/omarchy/omarchy-menu.jsonc').read_bytes() return replacements @@ -129,10 +130,14 @@ def labwc_files(directory, architecture='aarch64'): def desktop_additions(): - name = 'home/tablet/.local/share/omarchy-ipad/shell/bluetooth-pairing/shell.qml' - return {name: ((PROJECT / 'desktop/shell/shell/bluetooth-pairing/shell.qml').read_bytes(), 0o644), - 'home/tablet/.local/share/omarchy-ipad/bin/omarchy-rotation-lock': - ((PROJECT / 'desktop/shell/bin/omarchy-rotation-lock').read_bytes(), 0o755)} + prefix = 'home/tablet/.local/share/omarchy-ipad/' + return { + 'usr/local/bin/omarchy-rotate': ((PROJECT / 'desktop/shell/bin/omarchy-rotate').read_bytes(), 0o755), + 'home/tablet/.local/share/omarchy-ipad/bin/omarchy-rotate': ((PROJECT / 'desktop/shell/bin/omarchy-rotate').read_bytes(), 0o755), + 'usr/local/bin/omarchy-rotation-lock': ((PROJECT / 'desktop/shell/bin/omarchy-rotation-lock').read_bytes(), 0o755), + 'home/tablet/.local/share/omarchy-ipad/bin/omarchy-rotation-lock': ((PROJECT / 'desktop/shell/bin/omarchy-rotation-lock').read_bytes(), 0o755), + 'home/tablet/.local/share/omarchy-ipad/shell/bluetooth-pairing/shell.qml': ((PROJECT / 'desktop/shell/shell/bluetooth-pairing/shell.qml').read_bytes(), 0o644), + } def system_files(): From cc58e93e2d444f5671531e04b81385de78f1fcd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20K=C3=B6sebay?= Date: Thu, 12 Nov 2026 09:00:00 +0100 Subject: [PATCH 4/7] Power: cover lock, idle dim and suspend readiness The tablet had no lock on cover close, no dim before idle lock, and suspend was offered even when the kernel could not complete it. Lock on cover close, dim before the idle lock, disable hibernation, and gate suspend on ipadlinux-suspend-ready, the kernel's readiness report. Documented in docs/power-and-lock.md. Assisted-by: LLM --- .../shell/default/omarchy/omarchy-menu.jsonc | 2 +- .../shell/plugins/services/idle/Service.qml | 13 ++ desktop/stage.py | 6 - docs/power-and-lock.md | 75 ++++++++- release/public_root.py | 2 +- .../logind.conf.d/60-omarchy-a-power.conf | 4 + .../60-omarchy-a-no-hibernate.conf | 4 +- .../60-omarchy-a-qualification.conf | 4 + system/overlay/usr/libexec/omarchy-powerd | 80 +++++++++- .../usr/local/bin/ipadlinux-suspend-ready | 79 ++++++++++ tests/test_greeter_staging.py | 4 + tests/test_menu_shutdown.py | 11 +- tests/test_powerd.py | 68 +++++++- tests/test_suspend_ready.py | 148 ++++++++++++++++++ tests/test_tablet_port.py | 38 +++++ 15 files changed, 518 insertions(+), 20 deletions(-) create mode 100644 system/overlay/etc/systemd/system/systemd-suspend.service.d/60-omarchy-a-qualification.conf create mode 100755 system/overlay/usr/local/bin/ipadlinux-suspend-ready create mode 100644 tests/test_suspend_ready.py create mode 100644 tests/test_tablet_port.py diff --git a/desktop/shell/default/omarchy/omarchy-menu.jsonc b/desktop/shell/default/omarchy/omarchy-menu.jsonc index 28043c5..cf0eacc 100644 --- a/desktop/shell/default/omarchy/omarchy-menu.jsonc +++ b/desktop/shell/default/omarchy/omarchy-menu.jsonc @@ -35,7 +35,7 @@ // System "system.screensaver": {"icon":"󱄄","label":"Screensaver","action":"omarchy-launch-screensaver force"}, "system.lock": {"icon":"","label":"Lock","action":"omarchy-system-lock"}, - "system.suspend": {"icon":"󰒲","label":"Suspend","when":"! omarchy-toggle-enabled suspend-off && ! omarchy-sleep-disabled","action":"systemctl suspend"}, + "system.suspend": {"icon":"󰒲","label":"Suspend","when":"! omarchy-toggle-enabled suspend-off && /usr/local/bin/ipadlinux-suspend-ready","action":"/usr/local/bin/ipadlinux-suspend-ready && systemctl suspend"}, "system.hibernate": {"icon":"󰤁","label":"Hibernate","when":"omarchy-hibernation-available && ! omarchy-sleep-disabled","action":"systemctl hibernate"}, "system.logout": {"icon":"󰍃","label":"Logout","action":"omarchy-system logout"}, // Orchard: a reboot does not come back into Linux. The device's own boot chain starts its own system; Linux returns through DFU. diff --git a/desktop/shell/shell/plugins/services/idle/Service.qml b/desktop/shell/shell/plugins/services/idle/Service.qml index 9f0511c..a4e384b 100644 --- a/desktop/shell/shell/plugins/services/idle/Service.qml +++ b/desktop/shell/shell/plugins/services/idle/Service.qml @@ -285,6 +285,19 @@ Item { onTriggered: if (root.screensaverEnabled) root.launchScreensaver() } + // Dim before the 5-minute lock. The power daemon serializes dim/ALS/off; + // input restores only if a manual brightness change has not superseded dim. + IdleMonitor { + id: dimMonitor + timeout: 180 + enabled: Quickshell.env("OMARCHY_TOUCH_MODE") === "1" && root.idleEnabled && root.lockEnabled + onIsIdleChanged: { + dimProcess.command = ["/usr/libexec/omarchy-powerctl", isIdle ? "dim" : "undim"] + dimProcess.running = true + } + } + Process { id: dimProcess } + Timer { id: lockTimer repeat: false diff --git a/desktop/stage.py b/desktop/stage.py index 1cf4f79..b0c1ac0 100644 --- a/desktop/stage.py +++ b/desktop/stage.py @@ -123,12 +123,6 @@ def main(): copy(src, dst) # Branding is static; kernel, memory and CPU information use live probes. assert "VM prototype" not in (config / "fastfetch/config.jsonc").read_text() - # Offer Suspend only when the kernel-side charger PM contract is ready. - menu = source / "default/omarchy/omarchy-menu.jsonc" - original_menu = menu.read_text() - stock_guard = '"when":"! omarchy-toggle-enabled suspend-off && ! omarchy-sleep-disabled"' - assert original_menu.count(stock_guard) == 1 - menu.write_text(original_menu.replace(stock_guard, '"when":"command -v ipadlinux-suspend-ready >/dev/null && ipadlinux-suspend-ready"')) (config / "labwc/autostart").chmod(0o755) (target / "usr/local/libexec/arch-ipad-first-theme").chmod(0o755) (target / "usr/local/libexec/arch-ipad-scale-output").chmod(0o755) diff --git a/docs/power-and-lock.md b/docs/power-and-lock.md index 44c0cee..a07981c 100644 --- a/docs/power-and-lock.md +++ b/docs/power-and-lock.md @@ -1,11 +1,11 @@ # Power, lock and sleep: the state model -`omarchy-powerd` (`system/overlay/usr/libexec/omarchy-powerd`) is the one owner +`omarchy-powerd` (`system/overlay/usr/libexec/omarchy-powerd`) is the one maintainer of the screen, the lock request and sleep. Its state machine, the `Machine` class, has no I/O and no clocks; `tests/test_powerd.py` walks every reachable state with every input and checks the invariants below. Whether a board may -suspend at all is not decided here or by device data: every board may (see -orchard `docs/bring-up/suspend.md`); this model decides when. +suspend at all is decided by public device qualification, then kernel admission; +this model decides when. Unqualified boards retain dim, blank and wake. ## Requirements @@ -29,7 +29,7 @@ orchard `docs/bring-up/suspend.md`); this model decides when. 9. Being plugged in lights the screen, as on any tablet, asleep or awake. Being unplugged wakes the kernel (orchard) but stays dark and sleeps again. -## One owner +## One maintainer A system service, because it serves the greeter and the desktop alike, and because system services thaw with the kernel while `user.slice` stays frozen a @@ -217,3 +217,70 @@ when `user.slice` thaws. `omarchy-tablet-power` (one process per press, state in brightness and files, a fixed window after resume), `omarchy-screen-toggle` and `omarchy-greeter-wake` (a second and third writer of the backlight). + +## J71t Mac USB current profile + +`/etc/orchard/mac-usb-current` opts into Orchard's J71t Mac-current profile. +Merge and install the matching Orchard #108 hardware bundle first. Gadget +identity, host-grant handling and board/pack limits remain Orchard's policy; +this flag does not override the charger limits or host authorization. + +Pair with Orchard's stable USB serial path (#110). No root service should +rewrite the initrd's identity during switch-root. Cold and warm charging, +stable serial and USB traffic require exact-revision device checks. The +previous warm 500 mA observation remains unresolved; the flag alone does not +prove a higher current grant. Remove the flag and reboot to disable the profile. + +Use a freshly matched initrd from the integrated Orchard kernel bundle so its +stable USB identity and current-grant handling travel with the kernel modules. + +## Suspend qualification and readiness + +The root overlay installs `/usr/local/bin/ipadlinux-suspend-ready` for both +fresh roots and public-root refreshes. It first requires +`/usr/lib/orchard/device-feature suspend working`: Orchard's platform layer ships +that helper and `device-features.json`, matched against live DT compatibles. +Missing, unknown, untested, broken or ambiguous device data refuses sleep before +any kernel admission check. J71t currently declares suspend `untested`. +After qualification, it reads DeviceTree identity and checks admission below; +other qualified boards keep their existing logind/kernel path. Refusal returns 1 +with a reason on stderr. Arguments are +rejected with status 2. It never writes sysfs, unbinds drivers, runs a PM test +or requests suspend. The power daemon logs refusal reasons. + +On J71t it reads the kernel's admission query, `/sys/power/suspend_ready` +(Orchard's suspend topic): `ready` exits 0; `not-ready ` exits 1 +naming the refusing provider device or precheck. The kernel runs every +admission provider's non-reserving check (GPU, video decoder) and every early +precheck (PCI/NVMe, AOP, USB) for the current `mem_sleep` state, takes no +reservation, and answers `not-ready transition` while a transition runs. A +missing, unreadable or malformed answer refuses: an older kernel cannot prove +admission. The answer is advisory; the real suspend reserves and checks again. + +The source menu used by both desktop staging and `release/public_root.py` uses +this helper for visibility and again before its suspend action. Powerd uses it +for every sleep request (Power, idle and `cover-suspend`); a refusal leaves the +screen dark until touch/Home/Power wake, without retrying sleep. Cover-close +itself only locks/blanks. Logind ignores lid, idle, suspend and power keys; +keyboard or direct logind/systemctl requests still pass the same helper in +`systemd-suspend.service`'s `ExecStartPre`. Hibernation, hybrid sleep and +suspend-then-hibernate remain disabled and their targets masked. No static +suspend mask or board-specific qualification override is installed, so a board +whose registry changes to `working` can sleep after kernel admission succeeds. + +Device verification on a rebuilt image: + +```sh +timeout -k 5 5 /usr/lib/orchard/device-feature suspend working +timeout -k 5 10 /usr/local/bin/ipadlinux-suspend-ready +timeout -k 5 10 systemctl suspend +``` + +On the current unqualified J71t all three must refuse; the journal must name +qualification rather than a kernel suspend attempt, and suspend success +counters must not advance. Check that the final session menu omits Suspend. +Use `timeout -k 5 5 omarchy-powerctl idle`, cover-close/cover-suspend, and a +short Power press separately; each must blank without sleep and touch/Power +must wake. Dim/undim must still restore the previous brightness. Repeat after +reboot with the installed platform data; do not edit qualification on hardware +just to exercise sleep. These are pending device checks, not host-test results. diff --git a/release/public_root.py b/release/public_root.py index 45a909c..5780274 100644 --- a/release/public_root.py +++ b/release/public_root.py @@ -147,7 +147,7 @@ def system_files(): name = path.relative_to(SYSTEM).as_posix() data = path.read_bytes() mode = 0o440 if name.startswith('etc/sudoers.d/') else ( - 0o755 if name.startswith(('usr/libexec/', 'etc/NetworkManager/dispatcher.d/')) else 0o644) + 0o755 if name.startswith(('usr/libexec/', 'usr/bin/', 'usr/local/bin/', 'etc/NetworkManager/dispatcher.d/')) or name in ('usr/lib/orchard-kexec/quiesce-gpu', 'usr/lib/orchard-kexec/fdt-merge') else 0o644) additions[name] = (data, mode) return additions diff --git a/system/overlay/etc/systemd/logind.conf.d/60-omarchy-a-power.conf b/system/overlay/etc/systemd/logind.conf.d/60-omarchy-a-power.conf index 4c3908a..61a2812 100644 --- a/system/overlay/etc/systemd/logind.conf.d/60-omarchy-a-power.conf +++ b/system/overlay/etc/systemd/logind.conf.d/60-omarchy-a-power.conf @@ -3,3 +3,7 @@ HandlePowerKey=ignore HandleSuspendKey=ignore HandleHibernateKey=ignore IdleAction=ignore + +HandleLidSwitch=ignore +HandleLidSwitchExternalPower=ignore +HandleLidSwitchDocked=ignore diff --git a/system/overlay/etc/systemd/sleep.conf.d/60-omarchy-a-no-hibernate.conf b/system/overlay/etc/systemd/sleep.conf.d/60-omarchy-a-no-hibernate.conf index 01e716c..e0f8135 100644 --- a/system/overlay/etc/systemd/sleep.conf.d/60-omarchy-a-no-hibernate.conf +++ b/system/overlay/etc/systemd/sleep.conf.d/60-omarchy-a-no-hibernate.conf @@ -1,5 +1,5 @@ -# No hibernation: nothing restores an image on these devices. Every board may -# suspend; do not set AllowSuspend here. +# No hibernation: nothing restores an image on these devices. Suspend is +# qualified at runtime by systemd-suspend.service, using platform device data. [Sleep] AllowHibernation=no AllowHybridSleep=no diff --git a/system/overlay/etc/systemd/system/systemd-suspend.service.d/60-omarchy-a-qualification.conf b/system/overlay/etc/systemd/system/systemd-suspend.service.d/60-omarchy-a-qualification.conf new file mode 100644 index 0000000..876e8ca --- /dev/null +++ b/system/overlay/etc/systemd/system/systemd-suspend.service.d/60-omarchy-a-qualification.conf @@ -0,0 +1,4 @@ +# Enforce the same qualification/admission policy for direct systemctl/logind +# requests and keyboard bindings, even without a desktop or powerd running. +[Service] +ExecStartPre=/usr/local/bin/ipadlinux-suspend-ready diff --git a/system/overlay/usr/libexec/omarchy-powerd b/system/overlay/usr/libexec/omarchy-powerd index 6ebf69d..a45d119 100755 --- a/system/overlay/usr/libexec/omarchy-powerd +++ b/system/overlay/usr/libexec/omarchy-powerd @@ -341,6 +341,17 @@ class Buttons: return 'power' if short else None +def platform_suspend_ready(): + helper = '/usr/local/bin/ipadlinux-suspend-ready' + if not Path(helper).exists(): + log('no suspend: missing ' + helper) + return False + result = command(helper) + if result.returncode: + log('no suspend: ' + (result.stderr.strip() or 'platform readiness refused')) + return result.returncode == 0 + + def command(*argv, timeout=5, **options): return subprocess.run(argv, capture_output=True, text=True, timeout=timeout, **options) @@ -373,7 +384,6 @@ def as_user(user, *argv, timeout=5): class Daemon: def __init__(self): self.machine = Machine() - self.buttons = Buttons() self.events = queue.Queue() self.wake_read, self.wake_write = os.pipe() os.set_blocking(self.wake_read, False) @@ -385,8 +395,11 @@ class Daemon: self.saved = None self.counters = None self.sleep_started = None + self.buttons = Buttons() self.ticks = 0 self.cancel = threading.Event() + self.dim_saved = None + self.dim_level = None self.dark = threading.Event() self.session_jobs = queue.Queue() self.inhibitor = None @@ -503,13 +516,22 @@ class Daemon: getattr(self, 'do_' + action.replace('-', '_'))(before) self.dark.set() if self.machine.state in (SETTLING, DARK) else self.dark.clear() + def undim(self): + panel = self.backlight() + if self.dim_saved is not None and panel: + current = int((panel / 'brightness').read_text()) + if self.machine.state == ON and current == self.dim_level: + (panel / 'brightness').write_text(f'{self.dim_saved}\n') + self.dim_saved = self.dim_level = None + def do_dark(self, _): panel = self.backlight() if panel: current = int((panel / 'brightness').read_text()) if current > 0 and self.fade is None: - self.saved = current - self.remember_brightness(current) + self.saved = self.dim_saved or current + self.remember_brightness(self.saved) + self.dim_saved = self.dim_level = None self.start_fade(panel, 0) def start_fade(self, panel, target): @@ -646,6 +668,12 @@ class Daemon: def request_suspend(self): try: + # Shared with the final menu and systemd's direct suspend guard: + # public qualification first, then non-reserving kernel admission. + if not platform_suspend_ready(): + log('no suspend: platform readiness contract refused') + self.post('suspend-refused') + return answer = command('busctl', 'call', 'org.freedesktop.login1', '/org/freedesktop/login1', 'org.freedesktop.login1.Manager', 'CanSuspend').stdout.strip() if answer != 's "yes"': @@ -668,6 +696,8 @@ class Daemon: try: user = session_user() if job == 'lock': + if user is not None: + as_user(user, 'omarchy-keyboard', 'hide') self.post('lock-ok' if user is None or self.lock_session(user) else 'lock-failed') elif user is not None and job == 'woke': as_user(user, 'omarchy-shell', '-q', 'lock', 'woke') @@ -727,6 +757,48 @@ class Daemon: connection.sendall(answer.encode() + b'\n') elif request == 'status': connection.sendall(self.machine.state.encode() + b'\n') + elif request.startswith('brightness '): + parts = request.split() + panel = self.backlight() + reply = b'held\n' + if (len(parts) == 4 and panel and parts[1] == panel.name and + parts[2].isascii() and parts[2].isdigit() and + parts[3].isascii() and parts[3].isdigit()): + level, expected = int(parts[2]), int(parts[3]) + current = int((panel / 'brightness').read_text()) + maximum = int((panel / 'max_brightness').read_text()) + if self.machine.state == ON and self.fade is None and self.dim_saved is None and current > 0 and current == expected and 1 <= level <= maximum: + (panel / 'brightness').write_text(f'{level}\n') + reply = b'ok\n' + connection.sendall(reply) + elif request == 'als-state': + connection.sendall(b'lit\n' if self.machine.state == ON and self.fade is None and self.dim_saved is None else b'dark\n') + elif request == 'dim': + panel = self.backlight() + if self.machine.state == ON and self.dim_saved is None and panel: + current = int((panel / 'brightness').read_text()) + level = max(1, int((panel / 'max_brightness').read_text()) // 10) + if current > level: + self.dim_saved, self.dim_level = current, level + (panel / 'brightness').write_text(f'{level}\n') + connection.sendall(b'ok\n') + elif request == 'undim': + self.undim() + connection.sendall(b'ok\n') + elif request == 'cover-suspend': + if self.machine.state == DARK: + self.machine.no_suspend = False + self.feed('not-playing') + connection.sendall(b'ok\n') + elif request == 'cover-close': + self.feed('lock') + # Lock, darken and stay dark without automatic suspend. + # Cover polarity is opt-in; sleep remains separately qualified. + if self.machine.state == SETTLING: + self.settle_at = None + self.machine.state = DARK + self.machine.no_suspend = True + connection.sendall(b'ok\n') elif request in ('idle', 'wake', 'lock'): self.feed(request) connection.sendall(b'ok\n') @@ -803,7 +875,7 @@ class Daemon: self.restore_brightness() panel = self.backlight() if panel and int((panel / 'brightness').read_text()) == 0: - # State ON after a restart: never dark without an owner. + # State ON after a restart: never dark without an maintainer. self.key_time = time.monotonic() self.do_light(ON) server = self.open_socket() diff --git a/system/overlay/usr/local/bin/ipadlinux-suspend-ready b/system/overlay/usr/local/bin/ipadlinux-suspend-ready new file mode 100755 index 0000000..2127c8f --- /dev/null +++ b/system/overlay/usr/local/bin/ipadlinux-suspend-ready @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Read-only suspend policy probe; 0 permits a request, 1 refuses, 2 is usage. + +This never performs a suspend or reserves admission. The kernel remains the +final authority at the actual transition. Public device data must first +qualify suspend as working. Missing data/helper and all other states refuse. +On J71t admission comes from the kernel's own query, +/sys/power/suspend_ready ("ready", or +"not-ready "); PM callback presence, a loaded module and +power/state's "freeze" are not proof of admission. +""" +from pathlib import Path +import sys +import subprocess + +COMPATIBLE = Path('/sys/firmware/devicetree/base/compatible') +READINESS = Path('/sys/power/suspend_ready') + + +def admission(): + """The kernel's readiness answer; None when it reports ready.""" + try: + report = READINESS.read_text(encoding='ascii').strip() + except FileNotFoundError: + return ('J71t kernel has no suspend readiness query (/sys/power/suspend_ready); ' + 'GPU/VXD/SEP admission and platform prechecks cannot be verified') + except (OSError, UnicodeDecodeError) as error: + return f'cannot read kernel suspend readiness: {error}' + if report == 'ready': + return None + words = report.split() + if len(words) == 3 and words[0] == 'not-ready': + return f'kernel admission refused by {words[1]} ({words[2]})' + return f'malformed kernel suspend readiness: {report!r}' + + +def refusal(compatible): + if not compatible or not compatible.endswith(b'\0'): + return 'cannot identify the board from a valid DeviceTree compatible list' + if b'apple,j71t' in compatible.split(b'\0'): + # The kernel runs every admission provider's check and every early + # precheck without reserving; a later suspend still checks again. + return admission() + # Other qualified boards keep logind + the kernel's existing suspend path. + return None + + +def qualification(): + """Public platform data, never inferred from kernel admission.""" + try: + result = subprocess.run(['/usr/lib/orchard/device-feature', 'suspend', 'working'], + capture_output=True, text=True, timeout=5) + except (OSError, subprocess.SubprocessError) as error: + return f'cannot verify suspend qualification: {error}' + if result.returncode: + return 'public device registry does not qualify suspend as working' + return None + + +def main(argv=None): + if argv is None: + argv = sys.argv[1:] + if argv: + print('Usage: ipadlinux-suspend-ready', file=sys.stderr) + return 2 + try: + reason = qualification() + if reason is None: + reason = refusal(COMPATIBLE.read_bytes()) + except OSError as error: + reason = f'cannot read board identity: {error}' + if reason: + print(f'Suspend unavailable: {reason}', file=sys.stderr) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/test_greeter_staging.py b/tests/test_greeter_staging.py index 4aa427c..f846c8e 100644 --- a/tests/test_greeter_staging.py +++ b/tests/test_greeter_staging.py @@ -66,6 +66,10 @@ def test_trusted_ui_fallback_theme_and_tmpfiles_are_staged(self): self.assertEqual((greeter / 'plugins/lock/LockView.qml').read_bytes(), (stage.PORT / 'shell/plugins/lock/LockView.qml').read_bytes()) self.assertTrue((greeter / 'Commons/Color.qml').is_file()) + menu = 'default/omarchy/omarchy-menu.jsonc' + staged_menu = target / 'home/tablet/.local/share/omarchy-ipad' / menu + self.assertEqual(staged_menu.read_bytes(), (stage.PORT / menu).read_bytes()) + self.assertIn('/usr/local/bin/ipadlinux-suspend-ready', staged_menu.read_text()) jade = stage.HERE / 'themes/jade-horse' self.assertEqual((greeter / 'default-theme/background').read_bytes(), (jade / 'backgrounds/01-mountain-horses.webp').read_bytes()) diff --git a/tests/test_menu_shutdown.py b/tests/test_menu_shutdown.py index 5e4133b..65245ab 100644 --- a/tests/test_menu_shutdown.py +++ b/tests/test_menu_shutdown.py @@ -62,6 +62,15 @@ def test_menu_keeps_upstreams_calls(self): ('lock', 'omarchy-system-lock')): self.assertEqual(menu_action(name), action) + def test_final_and_staged_menu_share_the_suspend_gate(self): + from release.public_root import desktop_replacements + name = 'home/tablet/.local/share/omarchy-ipad/default/omarchy/omarchy-menu.jsonc' + self.assertEqual(desktop_replacements()[name], MENU.read_bytes()) + entry = json.loads(re.search(r'"system\.suspend":\s*(\{[^\n]+\})', MENU.read_text()).group(1)) + self.assertIn('/usr/local/bin/ipadlinux-suspend-ready', entry['when']) + self.assertNotIn('omarchy-sleep-disabled', entry['when']) + self.assertEqual(entry['action'], '/usr/local/bin/ipadlinux-suspend-ready && systemctl suspend') + def test_reboot_says_it_leaves_linux(self): # A reboot does not come back into Linux; one entry, no other system's name. entries = re.findall(r'"system\.reboot[^"]*":\s*(\{[^\n]+\})', MENU.read_text()) @@ -176,7 +185,7 @@ def test_the_shell_answers_both_calls(self): class PowerOwnerTests(unittest.TestCase): def test_lock_and_wake_only_feed_omarchy_powerd(self): - # docs/power-and-lock.md: one owner of the lock, the screen and sleep. + # docs/power-and-lock.md: one maintainer of the lock, the screen and sleep. for name, verb in (('omarchy-system-lock', 'lock'), ('omarchy-system-wake', 'wake')): lines = (BIN / name).read_text().splitlines() code = [line for line in lines if line and not line.startswith('#')] diff --git a/tests/test_powerd.py b/tests/test_powerd.py index 8b4edae..8770501 100644 --- a/tests/test_powerd.py +++ b/tests/test_powerd.py @@ -556,7 +556,7 @@ def setUp(self): ('STATS', self.stats), ('BACKLIGHTS', self.work / 'backlight'), ('BRIGHTNESS', self.remembered), ('WAKEUP_COUNT', self.work / 'wakeup_count'), ('SOCKET', str(self.work / 'ctl')), - ('session_user', lambda: None), ('command', self.fake_command), ('log', lambda *_: None)): + ('platform_suspend_ready', lambda: True), ('session_user', lambda: None), ('command', self.fake_command), ('log', lambda *_: None)): patcher = patch.object(powerd, target, value) patcher.start() self.addCleanup(patcher.stop) @@ -625,6 +625,72 @@ def ask(self, request): connection.sendall(request.encode() + b'\n') return connection.recv(64).decode().strip() + def test_home_socket_reports_combo_after_screenshot_chord(self): + when = time.monotonic() + event = powerd.INPUT_EVENT.pack + os.write(self.key_write, b''.join(event(int(when+i/10), int((when+i/10)%1*1e6), 1, code, down) + for i, (code, down) in enumerate(((116, 1), (172, 1), (172, 0), (116, 0))))) + self.wait(lambda: self.daemon.buttons.home_in_combo) + self.assertEqual(self.ask('home'), 'combo') + self.assertEqual(self.ask('status'), 'ON') + + def test_idle_dim_restore_and_manual_brightness_supersedes_dim(self): + self.assertEqual(self.ask('dim'), 'ok') + self.assertEqual(self.brightness(), '300') + self.assertEqual(self.ask('als-state'), 'dark') + self.assertEqual(self.ask('brightness panel 600 300'), 'held') + self.ask('undim'); self.assertEqual(self.brightness(), '1563') + self.ask('dim') + (self.panel / 'brightness').write_text('700\n') + self.ask('undim'); self.assertEqual(self.brightness(), '700') + + def test_cover_close_locks_without_automatic_suspend_and_open_wakes(self): + self.ask('cover-close') + self.wait(lambda: self.daemon.machine.lock_ok) + self.assertEqual(self.ask('status'), 'DARK') + self.assertEqual(self.brightness(), '0') + time.sleep(0.2) + self.assertNotIn(('systemctl', 'suspend'), self.requests) + self.ask('wake'); self.assertEqual(self.brightness(), '1563') + + def test_unqualified_power_idle_and_cover_suspend_stay_dark_and_can_wake(self): + with patch.object(powerd, 'platform_suspend_ready', return_value=False): + for entry in ('power', 'idle', 'cover-suspend'): + with self.subTest(entry=entry): + self.requests.clear() + if entry == 'power': + self.press() + elif entry == 'idle': + self.ask('idle') + else: + self.ask('cover-close') + self.ask('cover-suspend') + self.wait(lambda: self.daemon.machine.state == DARK and self.daemon.machine.no_suspend) + self.assertEqual(self.ask('status'), 'DARK') + self.assertEqual(self.brightness(), '0') + self.assertIsNone(self.daemon.settle_at) + self.assertNotIn(('systemctl', 'suspend'), self.requests) + self.assertFalse(any('CanSuspend' in call for call in self.requests)) + if entry == 'power': + self.press() + self.wait(lambda: self.brightness() == '1563') + else: + self.ask('wake') # touch/cover wake path + self.assertEqual(self.brightness(), '1563') + + def test_automatic_brightness_obeys_power_state_and_manual_changes(self): + self.assertEqual(self.ask('brightness panel 1800 1563'), 'ok') + self.assertEqual(self.brightness(), '1800') + self.assertEqual(self.ask('brightness panel 1900 1563'), 'held') + self.assertEqual(self.brightness(), '1800') + for request in ('brightness panel 0 1800', 'brightness panel 3001 1800', + 'brightness other 1900 1800', 'brightness panel ² 1800'): + self.assertEqual(self.ask(request), 'held') + self.assertEqual(self.brightness(), '1800') + self.press(); self.wait(lambda: self.brightness() == '0') + self.assertEqual(self.ask('brightness panel 1800 0'), 'held') + self.assertEqual(self.brightness(), '0') + def test_press_sleep_wake(self): machine = self.daemon.machine self.assertEqual(self.ask('input'), 'lit') diff --git a/tests/test_suspend_ready.py b/tests/test_suspend_ready.py new file mode 100644 index 0000000..d6cbbdb --- /dev/null +++ b/tests/test_suspend_ready.py @@ -0,0 +1,148 @@ +"""Readiness is a read-only policy check, never a speculative PM transition.""" +import contextlib +import importlib.machinery +import importlib.util +import io +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +HELPER = ROOT / 'system/overlay/usr/local/bin/ipadlinux-suspend-ready' + + +def load(name, path): + loader = importlib.machinery.SourceFileLoader(name, str(path)) + spec = importlib.util.spec_from_loader(name, loader) + module = importlib.util.module_from_spec(spec) + loader.exec_module(module) + return module + + +ready = load('suspend_ready', HELPER) +powerd = load('suspend_ready_powerd', ROOT / 'system/overlay/usr/libexec/omarchy-powerd') + + +class SuspendReady(unittest.TestCase): + def invoke(self, identity, argv=(), readiness=None): + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / 'compatible' + query = Path(directory) / 'suspend_ready' + if identity is not None: + path.write_bytes(identity) + if readiness is not None: + query.write_bytes(readiness) + out, err = io.StringIO(), io.StringIO() + with patch.object(ready, 'qualification', return_value=None), patch.object(ready, 'COMPATIBLE', path), patch.object(ready, 'READINESS', query), \ + contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = ready.main(argv) + if identity is not None: + self.assertEqual(path.read_bytes(), identity) + if readiness is not None: + self.assertEqual(query.read_bytes(), readiness) + return code, out.getvalue(), err.getvalue() + + def test_qualification_precedes_identity_and_kernel_admission(self): + with patch.object(ready, 'qualification', return_value='not qualified'), \ + patch.object(ready, 'refusal') as admission, \ + patch.object(ready.Path, 'read_bytes') as identity, \ + contextlib.redirect_stderr(io.StringIO()) as err: + self.assertEqual(ready.main([]), 1) + self.assertIn('not qualified', err.getvalue()) + identity.assert_not_called() + admission.assert_not_called() + + def test_qualification_uses_public_helper_and_fails_closed(self): + with patch.object(ready.subprocess, 'run') as run: + for status in (0, 1, 2): + run.return_value = subprocess.CompletedProcess([], status) + self.assertEqual(ready.qualification() is None, status == 0) + run.assert_called_with(['/usr/lib/orchard/device-feature', 'suspend', 'working'], + capture_output=True, text=True, timeout=5) + for error in (FileNotFoundError(), PermissionError(), subprocess.TimeoutExpired('helper', 5)): + run.side_effect = error + self.assertIn('cannot verify', ready.qualification()) + + def test_systemd_direct_suspend_uses_same_gate(self): + from release.public_root import system_files + dropin = 'etc/systemd/system/systemd-suspend.service.d/60-omarchy-a-qualification.conf' + data, mode = system_files()[dropin] + self.assertIn(b'ExecStartPre=/usr/local/bin/ipadlinux-suspend-ready', data) + self.assertEqual(mode, 0o644) + logind = system_files()['etc/systemd/logind.conf.d/60-omarchy-a-power.conf'][0].decode() + for setting in ('HandlePowerKey', 'HandleSuspendKey', 'HandleHibernateKey', + 'HandleLidSwitch', 'HandleLidSwitchExternalPower', 'HandleLidSwitchDocked', 'IdleAction'): + self.assertIn(setting + '=ignore', logind) + + def test_j71t_refuses_without_an_admission_query(self): + for identity in (b'apple,j71t\0apple,s8003\0', b'apple,j71t\0apple,s8000\0'): + code, out, err = self.invoke(identity) + self.assertEqual((code, out), (1, '')) + self.assertIn('no suspend readiness query', err) + self.assertIn('GPU/VXD/SEP', err) + + def test_j71t_follows_the_kernel_admission_query(self): + identity = b'apple,j71t\0apple,s8003\0' + self.assertEqual(self.invoke(identity, readiness=b'ready\n'), (0, '', '')) + code, out, err = self.invoke(identity, readiness=b'not-ready 201000000.gpu -16\n') + self.assertEqual((code, out), (1, '')) + self.assertIn('refused by 201000000.gpu (-16)', err) + code, _, err = self.invoke(identity, readiness=b'not-ready apple_h9p_sleep_precheck -16\n') + self.assertEqual(code, 1) + self.assertIn('apple_h9p_sleep_precheck', err) + + def test_j71t_malformed_kernel_answer_fails_closed(self): + for answer in (b'', b'readyish\n', b'not-ready\n', b'\xff\n'): + code, out, err = self.invoke(b'apple,j71t\0', readiness=answer) + self.assertEqual((code, out), (1, '')) + self.assertIn('Suspend unavailable:', err) + + def test_other_boards_ignore_the_kernel_query(self): + self.assertEqual(self.invoke(b'apple,j71b\0', readiness=b'not-ready x -16\n'), (0, '', '')) + + def test_other_boards_keep_logind_and_kernel_path(self): + for board in (b'apple,j71b', b'apple,j72b', b'apple,j71s', b'raspberrypi,4-model-b'): + self.assertEqual(self.invoke(board + b'\0apple,arm-platform\0'), (0, '', '')) + + def test_compatible_is_an_exact_nul_delimited_match(self): + self.assertEqual(self.invoke(b'apple,j71t-extra\0'), (0, '', '')) + self.assertEqual(self.invoke(b'apple,s8003\0apple,j71t\0')[0], 1) + + def test_missing_or_malformed_identity_fails_closed(self): + for identity in (None, b'', b'apple,j71t'): + code, out, err = self.invoke(identity) + self.assertEqual((code, out), (1, '')) + self.assertIn('Suspend unavailable:', err) + + def test_no_force_or_alternate_sysfs_option(self): + self.assertEqual(self.invoke(b'apple,j71t\0', ['--force'])[0], 2) + result = subprocess.run([sys.executable, str(HELPER), '--force'], capture_output=True, text=True, timeout=2) + self.assertEqual((result.returncode, result.stdout), (2, '')) + self.assertIn('Usage:', result.stderr) + + def test_refresh_ships_executable_helper(self): + from release.public_root import system_files + self.assertEqual(system_files()['usr/local/bin/ipadlinux-suspend-ready'], (HELPER.read_bytes(), 0o755)) + self.assertEqual(HELPER.stat().st_mode & 0o777, 0o755) + + def test_daemon_uses_exit_status_and_logs_reason(self): + messages = [] + with patch.object(powerd.Path, 'exists', return_value=True), patch.object(powerd, 'log', messages.append), patch.object(powerd, 'command') as command: + command.return_value = subprocess.CompletedProcess([], 1, '', 'Suspend unavailable: reason\n') + self.assertFalse(powerd.platform_suspend_ready()) + self.assertIn('reason', messages[-1]) + command.assert_called_once_with('/usr/local/bin/ipadlinux-suspend-ready') + command.return_value = subprocess.CompletedProcess([], 0, '', '') + self.assertTrue(powerd.platform_suspend_ready()) + + def test_daemon_refuses_missing_helper(self): + with patch.object(powerd.Path, 'exists', return_value=False), patch.object(powerd, 'log'), patch.object(powerd, 'command') as command: + self.assertFalse(powerd.platform_suspend_ready()) + command.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_tablet_port.py b/tests/test_tablet_port.py new file mode 100644 index 0000000..b2edcc8 --- /dev/null +++ b/tests/test_tablet_port.py @@ -0,0 +1,38 @@ +import importlib.machinery +import importlib.util +import json +import os +from pathlib import Path +import struct +import subprocess +import unittest +from unittest.mock import patch +import xml.etree.ElementTree as ET + +PROJECT = Path(__file__).resolve().parents[1] + +def load(name, path): + loader=importlib.machinery.SourceFileLoader(name,str(PROJECT/path)) + spec=importlib.util.spec_from_loader(name,loader) + mod=importlib.util.module_from_spec(spec); loader.exec_module(mod); return mod + +p=load('power','system/overlay/usr/libexec/omarchy-powerd') + +class TabletPort(unittest.TestCase): + pass + + + + + + def test_home_power_overlap_uses_upstream_screenshot_buttons(self): + p=load('power', 'system/overlay/usr/libexec/omarchy-powerd') + for edges in ((('power', True), ('home', True), ('power', False), ('home', False)), + (('home', True), ('power', True), ('home', False), ('power', False))): + buttons=p.Buttons() + result=[buttons.edge(name, down, 10+i/10, True) for i,(name,down) in enumerate(edges)] + self.assertEqual([x for x in result if x], ['home', 'screenshot']) + self.assertTrue(buttons.home_in_combo) + + +if __name__=='__main__': unittest.main() From f689e0991aae2e9f0d8b1a3a838994d71d226996 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20K=C3=B6sebay?= Date: Fri, 13 Nov 2026 04:49:43 +0100 Subject: [PATCH 5/7] Power: keep upstream's wording for powerd's role An earlier wording pass on this branch turned "owner" into "maintainer" in lines that come from main: powerd is the one owner of the screen, the lock request and sleep, and the shutdown menu test quotes that. Restore main's wording in the state model doc, the powerd comment and the test. Assisted-by: LLM --- docs/power-and-lock.md | 4 ++-- system/overlay/usr/libexec/omarchy-powerd | 2 +- tests/test_menu_shutdown.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/power-and-lock.md b/docs/power-and-lock.md index d0451a7..4cf7ccb 100644 --- a/docs/power-and-lock.md +++ b/docs/power-and-lock.md @@ -1,6 +1,6 @@ # Power, lock and sleep: the state model -`omarchy-powerd` (`system/overlay/usr/libexec/omarchy-powerd`) is the one maintainer +`omarchy-powerd` (`system/overlay/usr/libexec/omarchy-powerd`) is the one owner of the screen, the lock request and sleep. Its state machine, the `Machine` class, has no I/O and no clocks; `tests/test_powerd.py` walks every reachable state with every input and checks the invariants below. Whether a board may @@ -29,7 +29,7 @@ this model decides when. Unqualified boards retain dim, blank and wake. 9. Being plugged in lights the screen, as on any tablet, asleep or awake. Being unplugged wakes the kernel (orchard) but stays dark and sleeps again. -## One maintainer +## One owner A system service, because it serves the greeter and the desktop alike, and because system services thaw with the kernel while `user.slice` stays frozen a diff --git a/system/overlay/usr/libexec/omarchy-powerd b/system/overlay/usr/libexec/omarchy-powerd index f7a1aa3..52da960 100755 --- a/system/overlay/usr/libexec/omarchy-powerd +++ b/system/overlay/usr/libexec/omarchy-powerd @@ -915,7 +915,7 @@ class Daemon: self.restore_brightness() panel = self.backlight() if panel and int((panel / 'brightness').read_text()) == 0: - # State ON after a restart: never dark without an maintainer. + # State ON after a restart: never dark without an owner. self.key_time = time.monotonic() self.do_light(ON) server = self.open_socket() diff --git a/tests/test_menu_shutdown.py b/tests/test_menu_shutdown.py index 65245ab..3088831 100644 --- a/tests/test_menu_shutdown.py +++ b/tests/test_menu_shutdown.py @@ -185,7 +185,7 @@ def test_the_shell_answers_both_calls(self): class PowerOwnerTests(unittest.TestCase): def test_lock_and_wake_only_feed_omarchy_powerd(self): - # docs/power-and-lock.md: one maintainer of the lock, the screen and sleep. + # docs/power-and-lock.md: one owner of the lock, the screen and sleep. for name, verb in (('omarchy-system-lock', 'lock'), ('omarchy-system-wake', 'wake')): lines = (BIN / name).read_text().splitlines() code = [line for line in lines if line and not line.startswith('#')] From d5bb8b8d38e636db7749d731e86335578d7177b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20K=C3=B6sebay?= Date: Fri, 13 Nov 2026 11:08:36 +0100 Subject: [PATCH 6/7] Power: drop the J71t Mac USB current profile notes Orchard main now gives every installed system the Mac current identity (0x1014) on its own, and nothing reads /etc/orchard/mac-usb-current any more, so the notes on opting in with that flag describe a switch that no longer exists. Assisted-by: LLM --- docs/power-and-lock.md | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/docs/power-and-lock.md b/docs/power-and-lock.md index 4cf7ccb..64bc856 100644 --- a/docs/power-and-lock.md +++ b/docs/power-and-lock.md @@ -223,22 +223,6 @@ when `user.slice` thaws. fixed window after resume), `omarchy-screen-toggle` and `omarchy-greeter-wake` (a second and third writer of the backlight). -## J71t Mac USB current profile - -`/etc/orchard/mac-usb-current` opts into Orchard's J71t Mac-current profile. -Merge and install the matching Orchard #108 hardware bundle first. Gadget -identity, host-grant handling and board/pack limits remain Orchard's policy; -this flag does not override the charger limits or host authorization. - -Pair with Orchard's stable USB serial path (#110). No root service should -rewrite the initrd's identity during switch-root. Cold and warm charging, -stable serial and USB traffic require exact-revision device checks. The -previous warm 500 mA observation remains unresolved; the flag alone does not -prove a higher current grant. Remove the flag and reboot to disable the profile. - -Use a freshly matched initrd from the integrated Orchard kernel bundle so its -stable USB identity and current-grant handling travel with the kernel modules. - ## Suspend qualification and readiness The root overlay installs `/usr/local/bin/ipadlinux-suspend-ready` for both From 1412b2e981df68bc59fa00f957a01aa56b7ea7b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20K=C3=B6sebay?= Date: Fri, 13 Nov 2026 11:08:36 +0100 Subject: [PATCH 7/7] Power: take suspend readiness from the kernel alone ipadlinux-suspend-ready first asked Orchard's device-feature helper whether the board's feature table marked suspend as working. Orchard does not ship that table: feature states describe a board, they do not switch behaviour. Without the helper every sleep request was refused. Decide from the kernel's own admission query on every board instead. /sys/power/suspend_ready answers "ready" or names the first driver or precheck that would refuse; a kernel without the query cannot show that a suspend would be admitted, so sleep stays refused there, as do an unreadable or malformed answer. The helper no longer reads the board identity. Powerd, the session menu and systemd's direct suspend guard keep calling the same helper. Assisted-by: LLM --- docs/power-and-lock.md | 61 ++++++-------- .../60-omarchy-a-no-hibernate.conf | 2 +- .../60-omarchy-a-qualification.conf | 2 +- system/overlay/usr/libexec/omarchy-powerd | 2 +- .../usr/local/bin/ipadlinux-suspend-ready | 46 ++--------- tests/test_suspend_ready.py | 82 ++++++------------- 6 files changed, 63 insertions(+), 132 deletions(-) diff --git a/docs/power-and-lock.md b/docs/power-and-lock.md index 64bc856..90bf3b4 100644 --- a/docs/power-and-lock.md +++ b/docs/power-and-lock.md @@ -4,8 +4,8 @@ of the screen, the lock request and sleep. Its state machine, the `Machine` class, has no I/O and no clocks; `tests/test_powerd.py` walks every reachable state with every input and checks the invariants below. Whether a board may -suspend at all is decided by public device qualification, then kernel admission; -this model decides when. Unqualified boards retain dim, blank and wake. +suspend at all is decided by the kernel's suspend admission; this model decides +when. Where the kernel refuses, boards retain dim, blank and wake. ## Requirements @@ -223,28 +223,22 @@ when `user.slice` thaws. fixed window after resume), `omarchy-screen-toggle` and `omarchy-greeter-wake` (a second and third writer of the backlight). -## Suspend qualification and readiness +## Suspend readiness The root overlay installs `/usr/local/bin/ipadlinux-suspend-ready` for both -fresh roots and public-root refreshes. It first requires -`/usr/lib/orchard/device-feature suspend working`: Orchard's platform layer ships -that helper and `device-features.json`, matched against live DT compatibles. -Missing, unknown, untested, broken or ambiguous device data refuses sleep before -any kernel admission check. J71t currently declares suspend `untested`. -After qualification, it reads DeviceTree identity and checks admission below; -other qualified boards keep their existing logind/kernel path. Refusal returns 1 -with a reason on stderr. Arguments are -rejected with status 2. It never writes sysfs, unbinds drivers, runs a PM test -or requests suspend. The power daemon logs refusal reasons. - -On J71t it reads the kernel's admission query, `/sys/power/suspend_ready` -(Orchard's suspend topic): `ready` exits 0; `not-ready ` exits 1 -naming the refusing provider device or precheck. The kernel runs every -admission provider's non-reserving check (GPU, video decoder) and every early -precheck (PCI/NVMe, AOP, USB) for the current `mem_sleep` state, takes no -reservation, and answers `not-ready transition` while a transition runs. A -missing, unreadable or malformed answer refuses: an older kernel cannot prove -admission. The answer is advisory; the real suspend reserves and checks again. +fresh roots and public-root refreshes. It reads the kernel's admission query, +`/sys/power/suspend_ready` (Orchard's suspend topic), on every board: `ready` +exits 0; `not-ready ` exits 1 naming the refusing provider device +or precheck. The kernel runs every admission provider's non-reserving check +(GPU, video decoder) and every early precheck (PCI/NVMe, AOP, USB) for the +current `mem_sleep` state, takes no reservation, and answers +`not-ready transition` while a transition runs. A missing, unreadable or +malformed answer refuses: a kernel without the query cannot show that a suspend +would be admitted. There is no board list and no feature-state lookup. Refusal +returns 1 with a reason on stderr. Arguments are rejected with status 2. It +never writes sysfs, unbinds drivers, runs a PM test or requests suspend. The +power daemon logs refusal reasons. The answer is advisory; the real suspend +reserves and checks again. The source menu used by both desktop staging and `release/public_root.py` uses this helper for visibility and again before its suspend action. Powerd uses it @@ -254,22 +248,21 @@ without retrying sleep. Logind ignores lid, idle, suspend and power keys; keyboard or direct logind/systemctl requests still pass the same helper in `systemd-suspend.service`'s `ExecStartPre`. Hibernation, hybrid sleep and suspend-then-hibernate remain disabled and their targets masked. No static -suspend mask or board-specific qualification override is installed, so a board -whose registry changes to `working` can sleep after kernel admission succeeds. +suspend mask or board-specific override is installed: the kernel's admission is +the only gate. Device verification on a rebuilt image: ```sh -timeout -k 5 5 /usr/lib/orchard/device-feature suspend working +cat /sys/power/suspend_ready timeout -k 5 10 /usr/local/bin/ipadlinux-suspend-ready -timeout -k 5 10 systemctl suspend ``` -On the current unqualified J71t all three must refuse; the journal must name -qualification rather than a kernel suspend attempt, and suspend success -counters must not advance. Check that the final session menu omits Suspend. -Use `timeout -k 5 5 omarchy-powerctl idle`, closing the Smart Cover, and a -short Power press separately; each must blank without sleep, and touch, Power -or opening the cover must wake. Dim/undim must still restore the previous brightness. Repeat after -reboot with the installed platform data; do not edit qualification on hardware -just to exercise sleep. These are pending device checks, not host-test results. +The helper's status must match the kernel's answer: 0 for `ready`, 1 with the +same refusal named for `not-ready`. While it refuses, `systemctl suspend` must +refuse too, the journal must name the kernel's refusal rather than a suspend +attempt, suspend success counters must not advance, and the final session menu +must omit Suspend. Use `timeout -k 5 5 omarchy-powerctl idle`, closing the +Smart Cover, and a short Power press separately; while suspend is refused each +must blank without sleep, and touch, Power or opening the cover must wake. +Dim/undim must still restore the previous brightness. These are pending device checks, not host-test results. diff --git a/system/overlay/etc/systemd/sleep.conf.d/60-omarchy-a-no-hibernate.conf b/system/overlay/etc/systemd/sleep.conf.d/60-omarchy-a-no-hibernate.conf index e0f8135..9e0b786 100644 --- a/system/overlay/etc/systemd/sleep.conf.d/60-omarchy-a-no-hibernate.conf +++ b/system/overlay/etc/systemd/sleep.conf.d/60-omarchy-a-no-hibernate.conf @@ -1,5 +1,5 @@ # No hibernation: nothing restores an image on these devices. Suspend is -# qualified at runtime by systemd-suspend.service, using platform device data. +# checked at runtime by systemd-suspend.service, against the kernel's admission. [Sleep] AllowHibernation=no AllowHybridSleep=no diff --git a/system/overlay/etc/systemd/system/systemd-suspend.service.d/60-omarchy-a-qualification.conf b/system/overlay/etc/systemd/system/systemd-suspend.service.d/60-omarchy-a-qualification.conf index 876e8ca..4c9384e 100644 --- a/system/overlay/etc/systemd/system/systemd-suspend.service.d/60-omarchy-a-qualification.conf +++ b/system/overlay/etc/systemd/system/systemd-suspend.service.d/60-omarchy-a-qualification.conf @@ -1,4 +1,4 @@ -# Enforce the same qualification/admission policy for direct systemctl/logind +# Enforce the same kernel admission check for direct systemctl/logind # requests and keyboard bindings, even without a desktop or powerd running. [Service] ExecStartPre=/usr/local/bin/ipadlinux-suspend-ready diff --git a/system/overlay/usr/libexec/omarchy-powerd b/system/overlay/usr/libexec/omarchy-powerd index 52da960..30219dc 100755 --- a/system/overlay/usr/libexec/omarchy-powerd +++ b/system/overlay/usr/libexec/omarchy-powerd @@ -723,7 +723,7 @@ class Daemon: def request_suspend(self): try: # Shared with the final menu and systemd's direct suspend guard: - # public qualification first, then non-reserving kernel admission. + # the kernel's non-reserving admission query. if not platform_suspend_ready(): log('no suspend: platform readiness contract refused') self.post('suspend-refused') diff --git a/system/overlay/usr/local/bin/ipadlinux-suspend-ready b/system/overlay/usr/local/bin/ipadlinux-suspend-ready index 2127c8f..7e3e2fd 100755 --- a/system/overlay/usr/local/bin/ipadlinux-suspend-ready +++ b/system/overlay/usr/local/bin/ipadlinux-suspend-ready @@ -2,18 +2,16 @@ """Read-only suspend policy probe; 0 permits a request, 1 refuses, 2 is usage. This never performs a suspend or reserves admission. The kernel remains the -final authority at the actual transition. Public device data must first -qualify suspend as working. Missing data/helper and all other states refuse. -On J71t admission comes from the kernel's own query, -/sys/power/suspend_ready ("ready", or -"not-ready "); PM callback presence, a loaded module and +final authority at the actual transition. The answer comes from the kernel's +own admission query, /sys/power/suspend_ready ("ready", or +"not-ready "), on every board. A kernel without that query +cannot show that its drivers and platform prechecks would admit a suspend, so +suspend is refused there; PM callback presence, a loaded module and power/state's "freeze" are not proof of admission. """ from pathlib import Path import sys -import subprocess -COMPATIBLE = Path('/sys/firmware/devicetree/base/compatible') READINESS = Path('/sys/power/suspend_ready') @@ -22,8 +20,8 @@ def admission(): try: report = READINESS.read_text(encoding='ascii').strip() except FileNotFoundError: - return ('J71t kernel has no suspend readiness query (/sys/power/suspend_ready); ' - 'GPU/VXD/SEP admission and platform prechecks cannot be verified') + return ('the kernel has no suspend readiness query (/sys/power/suspend_ready); ' + 'driver admission and platform prechecks cannot be verified') except (OSError, UnicodeDecodeError) as error: return f'cannot read kernel suspend readiness: {error}' if report == 'ready': @@ -34,41 +32,13 @@ def admission(): return f'malformed kernel suspend readiness: {report!r}' -def refusal(compatible): - if not compatible or not compatible.endswith(b'\0'): - return 'cannot identify the board from a valid DeviceTree compatible list' - if b'apple,j71t' in compatible.split(b'\0'): - # The kernel runs every admission provider's check and every early - # precheck without reserving; a later suspend still checks again. - return admission() - # Other qualified boards keep logind + the kernel's existing suspend path. - return None - - -def qualification(): - """Public platform data, never inferred from kernel admission.""" - try: - result = subprocess.run(['/usr/lib/orchard/device-feature', 'suspend', 'working'], - capture_output=True, text=True, timeout=5) - except (OSError, subprocess.SubprocessError) as error: - return f'cannot verify suspend qualification: {error}' - if result.returncode: - return 'public device registry does not qualify suspend as working' - return None - - def main(argv=None): if argv is None: argv = sys.argv[1:] if argv: print('Usage: ipadlinux-suspend-ready', file=sys.stderr) return 2 - try: - reason = qualification() - if reason is None: - reason = refusal(COMPATIBLE.read_bytes()) - except OSError as error: - reason = f'cannot read board identity: {error}' + reason = admission() if reason: print(f'Suspend unavailable: {reason}', file=sys.stderr) return 1 diff --git a/tests/test_suspend_ready.py b/tests/test_suspend_ready.py index d6cbbdb..345121e 100644 --- a/tests/test_suspend_ready.py +++ b/tests/test_suspend_ready.py @@ -27,44 +27,23 @@ def load(name, path): class SuspendReady(unittest.TestCase): - def invoke(self, identity, argv=(), readiness=None): + def invoke(self, readiness=None, argv=()): with tempfile.TemporaryDirectory() as directory: - path = Path(directory) / 'compatible' query = Path(directory) / 'suspend_ready' - if identity is not None: - path.write_bytes(identity) if readiness is not None: query.write_bytes(readiness) out, err = io.StringIO(), io.StringIO() - with patch.object(ready, 'qualification', return_value=None), patch.object(ready, 'COMPATIBLE', path), patch.object(ready, 'READINESS', query), \ + with patch.object(ready, 'READINESS', query), \ contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): code = ready.main(argv) - if identity is not None: - self.assertEqual(path.read_bytes(), identity) if readiness is not None: self.assertEqual(query.read_bytes(), readiness) return code, out.getvalue(), err.getvalue() - def test_qualification_precedes_identity_and_kernel_admission(self): - with patch.object(ready, 'qualification', return_value='not qualified'), \ - patch.object(ready, 'refusal') as admission, \ - patch.object(ready.Path, 'read_bytes') as identity, \ - contextlib.redirect_stderr(io.StringIO()) as err: - self.assertEqual(ready.main([]), 1) - self.assertIn('not qualified', err.getvalue()) - identity.assert_not_called() - admission.assert_not_called() - - def test_qualification_uses_public_helper_and_fails_closed(self): - with patch.object(ready.subprocess, 'run') as run: - for status in (0, 1, 2): - run.return_value = subprocess.CompletedProcess([], status) - self.assertEqual(ready.qualification() is None, status == 0) - run.assert_called_with(['/usr/lib/orchard/device-feature', 'suspend', 'working'], - capture_output=True, text=True, timeout=5) - for error in (FileNotFoundError(), PermissionError(), subprocess.TimeoutExpired('helper', 5)): - run.side_effect = error - self.assertIn('cannot verify', ready.qualification()) + def test_no_feature_table_or_board_identity(self): + source = HELPER.read_text() + for name in ('device-feature', 'devicetree', 'compatible', 'subprocess'): + self.assertNotIn(name, source) def test_systemd_direct_suspend_uses_same_gate(self): from release.public_root import system_files @@ -77,48 +56,37 @@ def test_systemd_direct_suspend_uses_same_gate(self): 'HandleLidSwitch', 'HandleLidSwitchExternalPower', 'HandleLidSwitchDocked', 'IdleAction'): self.assertIn(setting + '=ignore', logind) - def test_j71t_refuses_without_an_admission_query(self): - for identity in (b'apple,j71t\0apple,s8003\0', b'apple,j71t\0apple,s8000\0'): - code, out, err = self.invoke(identity) - self.assertEqual((code, out), (1, '')) - self.assertIn('no suspend readiness query', err) - self.assertIn('GPU/VXD/SEP', err) + def test_refuses_without_an_admission_query(self): + code, out, err = self.invoke() + self.assertEqual((code, out), (1, '')) + self.assertIn('no suspend readiness query', err) - def test_j71t_follows_the_kernel_admission_query(self): - identity = b'apple,j71t\0apple,s8003\0' - self.assertEqual(self.invoke(identity, readiness=b'ready\n'), (0, '', '')) - code, out, err = self.invoke(identity, readiness=b'not-ready 201000000.gpu -16\n') + def test_follows_the_kernel_admission_query(self): + self.assertEqual(self.invoke(b'ready\n'), (0, '', '')) + code, out, err = self.invoke(b'not-ready 201000000.gpu -16\n') self.assertEqual((code, out), (1, '')) self.assertIn('refused by 201000000.gpu (-16)', err) - code, _, err = self.invoke(identity, readiness=b'not-ready apple_h9p_sleep_precheck -16\n') + code, _, err = self.invoke(b'not-ready apple_h9p_sleep_precheck -16\n') self.assertEqual(code, 1) self.assertIn('apple_h9p_sleep_precheck', err) + code, _, err = self.invoke(b'not-ready transition -16\n') + self.assertEqual(code, 1) + self.assertIn('refused by transition', err) - def test_j71t_malformed_kernel_answer_fails_closed(self): + def test_malformed_kernel_answer_fails_closed(self): for answer in (b'', b'readyish\n', b'not-ready\n', b'\xff\n'): - code, out, err = self.invoke(b'apple,j71t\0', readiness=answer) + code, out, err = self.invoke(answer) self.assertEqual((code, out), (1, '')) self.assertIn('Suspend unavailable:', err) - def test_other_boards_ignore_the_kernel_query(self): - self.assertEqual(self.invoke(b'apple,j71b\0', readiness=b'not-ready x -16\n'), (0, '', '')) - - def test_other_boards_keep_logind_and_kernel_path(self): - for board in (b'apple,j71b', b'apple,j72b', b'apple,j71s', b'raspberrypi,4-model-b'): - self.assertEqual(self.invoke(board + b'\0apple,arm-platform\0'), (0, '', '')) - - def test_compatible_is_an_exact_nul_delimited_match(self): - self.assertEqual(self.invoke(b'apple,j71t-extra\0'), (0, '', '')) - self.assertEqual(self.invoke(b'apple,s8003\0apple,j71t\0')[0], 1) - - def test_missing_or_malformed_identity_fails_closed(self): - for identity in (None, b'', b'apple,j71t'): - code, out, err = self.invoke(identity) - self.assertEqual((code, out), (1, '')) - self.assertIn('Suspend unavailable:', err) + def test_unreadable_answer_fails_closed(self): + with patch.object(ready.Path, 'read_text', side_effect=PermissionError('denied')), \ + contextlib.redirect_stderr(io.StringIO()) as err: + self.assertEqual(ready.main([]), 1) + self.assertIn('cannot read kernel suspend readiness', err.getvalue()) def test_no_force_or_alternate_sysfs_option(self): - self.assertEqual(self.invoke(b'apple,j71t\0', ['--force'])[0], 2) + self.assertEqual(self.invoke(b'ready\n', ['--force'])[0], 2) result = subprocess.run([sys.executable, str(HELPER), '--force'], capture_output=True, text=True, timeout=2) self.assertEqual((result.returncode, result.stdout), (2, '')) self.assertIn('Usage:', result.stderr)