Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions desktop/shell/bin/omarchy-rotate
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion desktop/shell/default/omarchy/omarchy-menu.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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\""},
Expand Down
13 changes: 13 additions & 0 deletions desktop/shell/shell/plugins/services/idle/Service.qml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 0 additions & 6 deletions desktop/stage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
48 changes: 46 additions & 2 deletions docs/power-and-lock.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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 the kernel's suspend admission; this model decides
when. Where the kernel refuses, boards retain dim, blank and wake.

## Requirements

Expand Down Expand Up @@ -222,3 +222,47 @@ 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).

## Suspend readiness

The root overlay installs `/usr/local/bin/ipadlinux-suspend-ready` for both
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 <who> <errno>` 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
for every sleep request (Power, idle and closing the Smart Cover); a refusal
leaves the screen dark until touch, Home, Power or opening the cover wakes it,
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 override is installed: the kernel's admission is
the only gate.

Device verification on a rebuilt image:

```sh
cat /sys/power/suspend_ready
timeout -k 5 10 /usr/local/bin/ipadlinux-suspend-ready
```

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.
15 changes: 10 additions & 5 deletions release/public_root.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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():
Expand All @@ -142,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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@ HandlePowerKey=ignore
HandleSuspendKey=ignore
HandleHibernateKey=ignore
IdleAction=ignore

HandleLidSwitch=ignore
HandleLidSwitchExternalPower=ignore
HandleLidSwitchDocked=ignore
Original file line number Diff line number Diff line change
@@ -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
# checked at runtime by systemd-suspend.service, against the kernel's admission.
[Sleep]
AllowHibernation=no
AllowHybridSleep=no
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# 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
64 changes: 61 additions & 3 deletions system/overlay/usr/libexec/omarchy-powerd
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,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)

Expand Down Expand Up @@ -414,7 +425,6 @@ def as_user(user, *argv, timeout=5):
class Daemon:
def __init__(self):
self.machine = Machine()
self.buttons = Buttons()
self.cover = Cover()
self.covers = {}
self.events = queue.Queue()
Expand All @@ -428,8 +438,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
Expand Down Expand Up @@ -557,13 +570,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):
Expand Down Expand Up @@ -700,6 +722,12 @@ class Daemon:

def request_suspend(self):
try:
# Shared with the final menu and systemd's direct suspend guard:
# the kernel's non-reserving admission query.
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"':
Expand All @@ -722,6 +750,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')
Expand Down Expand Up @@ -781,6 +811,34 @@ 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 in ('idle', 'wake', 'lock'):
self.feed(request)
connection.sendall(b'ok\n')
Expand Down
49 changes: 49 additions & 0 deletions system/overlay/usr/local/bin/ipadlinux-suspend-ready
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/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. The answer comes from the kernel's
own admission query, /sys/power/suspend_ready ("ready", or
"not-ready <who> <errno>"), 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

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 ('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':
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 main(argv=None):
if argv is None:
argv = sys.argv[1:]
if argv:
print('Usage: ipadlinux-suspend-ready', file=sys.stderr)
return 2
reason = admission()
if reason:
print(f'Suspend unavailable: {reason}', file=sys.stderr)
return 1
return 0


if __name__ == '__main__':
sys.exit(main())
4 changes: 4 additions & 0 deletions tests/test_greeter_staging.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading
Loading