Compare commits
20
Commits
ce95c9be9c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c4fc138e3 | ||
|
|
5b5be2979e | ||
|
|
429456de37 | ||
|
|
4ec1f015c8 | ||
|
|
03eb10d3db | ||
|
|
a7364eb642 | ||
|
|
9fe57b916b | ||
|
|
b144445f0d | ||
|
|
1fbd72a2ba | ||
|
|
78a9da3deb | ||
|
|
076561c428 | ||
|
|
000ff69560 | ||
|
|
96759d68fd | ||
|
|
55a0b5a06c | ||
|
|
a51586fce4 | ||
|
|
d0d23fb806 | ||
|
|
a4953b8c43 | ||
|
|
700b9b4a5d | ||
|
|
b55f925281 | ||
|
|
1c25b44b8e |
@@ -23,12 +23,12 @@ My current setup is built around these core components:
|
||||
### Directory Structure
|
||||
|
||||
- **`hypr/`**: Hyprland configurations (keybindings, window rules, animations, monitor layout, lid-close display handling, and autostart). See [`hypr/keybinds.md`](hypr/keybinds.md) for custom keybindings.
|
||||
- **`omarchy/`**: Omarchy 4 configuration — `shell.json` (bar layout, custom bar modules, and idle/lock timings), `extensions/` (entries added to the Omarchy menu), `hooks/` (event hooks such as retinting AGS on theme change), `plugins/` (cloned shell plugins for the bar), and `themed/` (extra theme templates). See [`omarchy/README.md`](omarchy/README.md) for the bar layout and the plugin clones.
|
||||
- **`omarchy/`**: Omarchy 4 configuration — `shell.json` (bar layout, custom bar modules, and idle/lock timings), `extensions/` (entries added to the Omarchy menu), `hooks/` (event hooks such as retinting AGS on theme change), `plugins/` (forked shell plugins: the bar itself, the menu, and the workspace switcher), and `themed/` (extra theme templates). See [`omarchy/README.md`](omarchy/README.md) for the bar layout and the plugin clones.
|
||||
- **`ags/`**: Custom desktop widgets built with TypeScript and GTK — media player, notification hub, quick settings, system monitor, wallpaper picker, theme picker, and Claude Code usage.
|
||||
- **`scripts/`**: Global utility scripts seamlessly exposed as commands by the installer. See [`commands.md`](commands.md).
|
||||
- **`wallpapers/`**: A collection of local custom wallpapers for dynamic theming. See [`wallpaper-gallery/`](wallpaper-gallery/index.md) for the full gallery (split alphabetically across multiple pages).
|
||||
- **`themes/`**: Drop-in local color themes (one `colors.toml` per theme) that `install.sh` deploys into Omarchy's theme directory. See [`themes/README.md`](themes/README.md).
|
||||
- **`branding/`**: Custom ASCII art and system branding assets.
|
||||
- **`branding/`**: Custom ASCII art and system branding assets. `blob_icon.svg` is the bar menu button.
|
||||
- **`kitty/`**: kitty terminal config and its color theme, applied by hand rather than by `install.sh`. See [`kitty/README.md`](kitty/README.md).
|
||||
|
||||
## Docs
|
||||
|
||||
@@ -66,6 +66,14 @@ function localDateKey(date: Date) {
|
||||
return `${date.getFullYear()}-${month}-${day}`
|
||||
}
|
||||
|
||||
function formatClockTime(date: Date) {
|
||||
const hourOfDay = date.getHours()
|
||||
const minutes = `${date.getMinutes()}`.padStart(2, "0")
|
||||
const hour12 = hourOfDay % 12 === 0 ? 12 : hourOfDay % 12
|
||||
const suffix = hourOfDay < 12 ? "AM" : "PM"
|
||||
return `${hour12}:${minutes}${suffix}`
|
||||
}
|
||||
|
||||
function formatResetsIn(resetsAt: string) {
|
||||
const resetTime = Date.parse(resetsAt)
|
||||
if (!resetTime) return ""
|
||||
@@ -76,10 +84,11 @@ function formatResetsIn(resetsAt: string) {
|
||||
const days = Math.floor(minutesLeft / 1440)
|
||||
const hours = Math.floor((minutesLeft % 1440) / 60)
|
||||
const minutes = minutesLeft % 60
|
||||
const clockTime = formatClockTime(new Date(resetTime))
|
||||
|
||||
if (days > 0) return `Resets in ${days}d ${hours}h`
|
||||
if (hours > 0) return `Resets in ${hours}h ${minutes}m`
|
||||
return `Resets in ${minutes}m`
|
||||
if (days > 0) return `Resets in ${days}d ${hours}h (${clockTime})`
|
||||
if (hours > 0) return `Resets in ${hours}h ${minutes}m (${clockTime})`
|
||||
return `Resets in ${minutes}m (${clockTime})`
|
||||
}
|
||||
|
||||
function formatModelName(model: string) {
|
||||
|
||||
@@ -57,6 +57,11 @@ const [stayAwake, setStayAwake] = pollBool(
|
||||
2000,
|
||||
)
|
||||
|
||||
const [tabletFollow, setTabletFollow] = pollBool(
|
||||
"omarchy-toggle-tablet-follow status 2>/dev/null | grep -q '\"enabled\":true' && echo on || echo off",
|
||||
2000,
|
||||
)
|
||||
|
||||
const [recording] = pollBool(
|
||||
"pgrep -f '^gpu-screen-recorder' >/dev/null && echo on || echo off",
|
||||
2000,
|
||||
@@ -219,6 +224,20 @@ function Toggles() {
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
<box class="qs-tiles" spacing={8} homogeneous>
|
||||
<Tile
|
||||
icon={""}
|
||||
label="Tablet Lock"
|
||||
active={tabletFollow}
|
||||
tooltip={tabletFollow.as((on: boolean) =>
|
||||
on ? "Tablet follows focused monitor" : "Tablet spans all monitors",
|
||||
)}
|
||||
onClicked={() => {
|
||||
setTabletFollow(!tabletFollow.get())
|
||||
sh("omarchy-toggle-tablet-follow toggle")
|
||||
}}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -450,7 +469,7 @@ function WeatherCard() {
|
||||
/>
|
||||
<box vertical spacing={8} visible={weather.as((w) => w.ok)}>
|
||||
<StatRow icon={""} label="Temperature" value={weather.as((w) => w.temp)} />
|
||||
<StatRow icon={""} label="Wind" value={weather.as((w) => w.wind)} />
|
||||
<StatRow icon={""} label="Wind" value={weather.as((w) => w.wind)} />
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
+26
-26
@@ -1,26 +1,26 @@
|
||||
█████████████████████████████████████████████████████
|
||||
█████████████████████████████████████████████████████
|
||||
████ ████ ████
|
||||
████ ████ ████
|
||||
████ █████████████████████ ████████ ████
|
||||
████ █████████████████████ ████████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████████████ ████ ████
|
||||
████████████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ████ ████ ████
|
||||
████ ██████████████████████████████████████ ████
|
||||
████ ██████████████████████████████████████ ████
|
||||
████ ████ ████
|
||||
████ ████ ████
|
||||
████████████████████████████ ████████████████████
|
||||
████████████████████████████ ████████████████████
|
||||
@@@@@@@@@@@@
|
||||
@@@@ @@@@
|
||||
@@@ @@@
|
||||
@@@ @@@
|
||||
@@@ @@
|
||||
@@ @@ @@ @@
|
||||
@ @@ @@ @@ @@ @@
|
||||
@ @@ ##### @ @ @ @@
|
||||
@ @ ##### @ @ ##### @ @@
|
||||
@@ @ ### @ @ ###### @ @@
|
||||
@ @ @ @ ######@@ @@
|
||||
@@ @@@ @@@ @@ @@@ @
|
||||
@@ @@
|
||||
@@ @@
|
||||
@@ @
|
||||
@ @@
|
||||
@@ @@
|
||||
@@ @@
|
||||
@ @@
|
||||
@@ @@
|
||||
@@ @@
|
||||
@@@ @@
|
||||
@@@ @@@
|
||||
@@@ @@@
|
||||
@@@@ @@@@
|
||||
@@@@@@@@@@@@
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN"
|
||||
"http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
|
||||
<svg version="1.0" xmlns="http://www.w3.org/2000/svg"
|
||||
width="520.000000pt" height="511.000000pt" viewBox="0 0 520.000000 511.000000"
|
||||
preserveAspectRatio="xMidYMid meet">
|
||||
<g transform="translate(0.000000,511.000000) scale(0.050000,-0.050000)"
|
||||
fill="#000000" stroke="none">
|
||||
<path d="M4790 10147 c-3157 -250 -5335 -3216 -4578 -6237 784 -3132 4321
|
||||
-4735 7258 -3290 2905 1429 3734 5102 1723 7628 -1028 1292 -2741 2030 -4403
|
||||
1899z m1090 -337 c3940 -604 5501 -5305 2677 -8061 -2334 -2278 -6268 -1614
|
||||
-7707 1301 -1482 3002 510 6440 3940 6798 173 18 888 -7 1090 -38z"/>
|
||||
<path d="M2810 8049 c-22 -5 -89 -19 -150 -31 -998 -194 -1377 -1466 -652
|
||||
-2188 1078 -1073 2875 139 2209 1490 -238 483 -926 840 -1407 729z m550 -269
|
||||
c782 -263 1032 -1216 470 -1788 -633 -642 -1757 -350 -1959 510 -195 826 658
|
||||
1558 1489 1278z"/>
|
||||
<path d="M2626 7544 c-486 -174 -571 -762 -147 -1018 572 -345 1209 298 812
|
||||
819 -142 185 -447 277 -665 199z"/>
|
||||
<path d="M6930 8029 c-1037 -228 -1420 -1465 -682 -2199 855 -850 2351 -270
|
||||
2351 910 0 832 -830 1473 -1669 1289z m689 -256 c777 -270 1012 -1233 437
|
||||
-1796 -638 -624 -1745 -325 -1945 525 -197 836 666 1564 1508 1271z"/>
|
||||
<path d="M7295 7073 c-373 -105 -571 -506 -415 -839 286 -608 1249 -418 1249
|
||||
246 0 411 -420 710 -834 593z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -1,2 +1,4 @@
|
||||
-- Extra autostart processes.
|
||||
o.launch_on_start("ags run")
|
||||
|
||||
o.launch_on_start("omarchy-toggle-tablet-follow autostart")
|
||||
|
||||
@@ -205,6 +205,8 @@ check_file "$SCRIPT_DIR/omarchy/shell.json" "$HOME_DIR/.config/omarchy/shell.jso
|
||||
check_file "$SCRIPT_DIR/omarchy/extensions/omarchy-menu.jsonc" "$HOME_DIR/.config/omarchy/extensions/omarchy-menu.jsonc" "omarchy/extensions/omarchy-menu.jsonc" || check_status=1
|
||||
check_file "$SCRIPT_DIR/omarchy/plugins/blob.workspaces/Workspaces.qml" "$HOME_DIR/.config/omarchy/plugins/blob.workspaces/Workspaces.qml" "omarchy/plugins/blob.workspaces" || check_status=1
|
||||
check_file "$SCRIPT_DIR/omarchy/plugins/blob.menu/Menu.qml" "$HOME_DIR/.config/omarchy/plugins/blob.menu/Menu.qml" "omarchy/plugins/blob.menu" || check_status=1
|
||||
check_file "$SCRIPT_DIR/omarchy/plugins/blob.bar/Bar.qml" "$HOME_DIR/.config/omarchy/plugins/blob.bar/Bar.qml" "omarchy/plugins/blob.bar" || check_status=1
|
||||
check_file "$SCRIPT_DIR/omarchy/plugins/blob.lock/LockView.qml" "$HOME_DIR/.config/omarchy/plugins/blob.lock/LockView.qml" "omarchy/plugins/blob.lock" || check_status=1
|
||||
check_file "$SCRIPT_DIR/omarchy/hooks/theme-set" "$HOME_DIR/.config/omarchy/hooks/theme-set" "omarchy/hooks/theme-set" || check_status=1
|
||||
check_file "$SCRIPT_DIR/omarchy/themed/zen.css.tpl" "$HOME_DIR/.config/omarchy/themed/zen.css.tpl" "omarchy/themed/zen.css.tpl" || check_status=1
|
||||
check_file "$SCRIPT_DIR/ags/app.ts" "$HOME_DIR/.config/ags/app.ts" "ags/app.ts" || check_status=1
|
||||
|
||||
+132
-17
@@ -23,7 +23,7 @@ Ported from the old `waybar/config.jsonc`, section for section:
|
||||
| `network` | `omarchy.network` |
|
||||
| `pulseaudio` | `omarchy.audio` |
|
||||
| `battery` | `omarchy.power` |
|
||||
| `cpu` | `blob.cpu` (custom module) |
|
||||
| `cpu` | dropped (btop and the AGS system monitor have no bar entry point now) |
|
||||
| `custom/notification` | `blob.notifications` (custom module) |
|
||||
| (new in Omarchy 4) | `omarchy.agents` - AI agent usage |
|
||||
| (new in Omarchy 4) | `omarchy.monitor` - brightness and display controls |
|
||||
@@ -34,13 +34,27 @@ update indicator in its center. Add `{ "id": "omarchy.indicators" }` back to
|
||||
`center` to restore them (omit `items` for all six, which adds `NightLight`
|
||||
and `Reminder`).
|
||||
|
||||
## Idle timings
|
||||
|
||||
`idle.screensaver` and `idle.lock` are both counted from the moment the session
|
||||
goes idle, not from each other. The screensaver is what paints
|
||||
`branding/screensaver.txt` across every monitor, so the two values need a real
|
||||
gap between them or the lock screen covers the branding as soon as it appears.
|
||||
Screensaver at 300 and lock at 900 leaves ten minutes of branding before the
|
||||
session locks.
|
||||
|
||||
Neither timer runs while `~/.local/state/omarchy/indicators/stay-awake` exists.
|
||||
That file is the Stay Awake toggle, it is runtime state rather than config so
|
||||
it is not tracked here, and while it is set the idle service cancels every
|
||||
cycle with `idle-cycle-cancel: stay-awake`. `omarchy-toggle-idle allow-idle`
|
||||
clears it. The lock screen carries the branding on its own (see
|
||||
`plugins/blob.lock/` below), so this only affects the screensaver.
|
||||
|
||||
## Custom modules
|
||||
|
||||
The bar accepts arbitrary ids with `type: "command"`, which is how the two
|
||||
AGS entry points survive the move off waybar:
|
||||
The bar accepts arbitrary ids with `type: "command"`, which is how the AGS
|
||||
entry points survive the move off waybar:
|
||||
|
||||
- `blob.cpu` - left opens btop, middle opens alacritty, right toggles the AGS
|
||||
system monitor
|
||||
- `blob.notifications` - left toggles the AGS notification center, right
|
||||
toggles notification silencing
|
||||
- `blob.clock` - left toggles AGS quick settings, middle opens the shell's own
|
||||
@@ -53,24 +67,125 @@ A command module with no `exec` key is a static icon; add `exec` and
|
||||
|
||||
## Cloned plugins
|
||||
|
||||
Both were made with `omarchy plugin clone`, which copies a built-in plugin,
|
||||
disables the original, and points the bar at the copy - hence the `blob.` ids
|
||||
in `shell.json`.
|
||||
`blob.workspaces` and `blob.menu` were made with `omarchy plugin clone`, which
|
||||
copies a built-in plugin, disables the original, and points the bar at the copy
|
||||
- hence the `blob.` ids in `shell.json`. `blob.bar` and `blob.lock` were copied
|
||||
by hand; see below for why.
|
||||
|
||||
`plugins/blob.workspaces/` clones `omarchy.workspaces`. The stock widget
|
||||
hardcodes workspaces 1-5 as always visible and has no setting for it, so the
|
||||
clone changes that list to 1-9 to match the old waybar `persistent-workspaces`.
|
||||
|
||||
`plugins/blob.menu/` clones `omarchy.menu` to widen it. `cardWidth` in
|
||||
`Menu.qml` is hardcoded at `Style.space(300)`; the clone raises it to 440, so
|
||||
the apps menu (Super + Space) and the root menu (Super + Alt + Space) are both
|
||||
wider. The two oversized menus (screen recording, font picker) keep their own
|
||||
520 and are untouched. `omarchy-menu` still targets `omarchy.menu` on the CLI -
|
||||
the manifest records `clonedFrom`, and the shell routes those calls here.
|
||||
`plugins/blob.menu/` clones `omarchy.menu` to widen it, to let a menu row
|
||||
choose where it sits, and to put the Blob icon on the bar button. `cardWidth` in `Menu.qml` is hardcoded at
|
||||
`Style.space(300)`; the clone raises it to 440, so the apps menu (Super + Space)
|
||||
and the root menu (Super + Alt + Space) are both wider. The two oversized menus
|
||||
(screen recording, font picker) keep their own 520 and are untouched.
|
||||
`omarchy-menu` still targets `omarchy.menu` on the CLI - the manifest records
|
||||
`clonedFrom`, and the shell routes those calls here.
|
||||
|
||||
Re-clone after an Omarchy update if the upstream widget gains something worth
|
||||
picking up: `omarchy plugin clone omarchy.workspaces`, then re-apply the
|
||||
one-line `workspaceIds()` change.
|
||||
`BarWidget.qml` swaps the stock `\ue900` glyph from the `omarchy` icon font for
|
||||
`branding/blob_icon.svg`. An SVG is text, so the fill is rewritten to the bar
|
||||
foreground in QML and the result handed to `Image` as a base64 `data:` URL -
|
||||
the same trick the `theme-set` hook uses with `sed` for the fastfetch logo, so
|
||||
both consumers of the icon work the same way. The color is read off
|
||||
`button.foreground` rather than the theme, which keeps the icon on the bar's own
|
||||
foreground animation, and rebuilding the URL is what reloads the image, so there
|
||||
is no image cache to bust. The glyph stays as the `text` fallback and is shown
|
||||
whenever the SVG cannot be read or decoded, so the button is never blank.
|
||||
|
||||
`mergeMenuSources` in `MenuModel.js` reads the default menu first and the user
|
||||
extension second, so a row that only exists in `extensions/omarchy-menu.jsonc`
|
||||
lands at the bottom of its menu with no way to move it. The clone adds a
|
||||
`before:` key naming another row's id, and `applyBeforeHints` moves the row
|
||||
ahead of it once both sources are merged - which is how `Blob` sits directly
|
||||
under `Apps` instead of below `System`. A `before:` that names an unknown id is
|
||||
ignored, and a row without one keeps its file order.
|
||||
|
||||
`plugins/blob.lock/` clones `omarchy.lock` to put `branding/screensaver.txt` on
|
||||
the lock screen and to restyle the password field like the AGS widgets. Stock
|
||||
`LockView.qml` draws a blurred wallpaper, the field, and a fingerprint hint -
|
||||
there is no branding element and no config key for one, so a fork is the only
|
||||
way in.
|
||||
|
||||
The fork is three small changes. `BrandingSource.qml` is new and holds both
|
||||
file reads: the branding text, and the `color4` slot parsed out of the active
|
||||
theme's `colors.toml`. `Color` resolves a palette down to `foreground`,
|
||||
`background`, `accent`, `urgent`, and `muted` and never exposes `color4`, which
|
||||
is the color AGS borders with. Stock `Color` reads that file once at startup
|
||||
and takes theme switches over IPC, but
|
||||
`~/.local/state/omarchy/current/theme` is a real directory rewritten in place
|
||||
rather than a swapped symlink, so watching the file is enough here.
|
||||
`Service.qml` gains only that component and two bindings on each of its two
|
||||
`LockView` instances (the lock surface and the theme preview). `LockView.qml`
|
||||
gains a `Text` above the field, and the field's own skin.
|
||||
|
||||
The styling follows `ags/style.css`: a 2px border and square corners in place
|
||||
of the shell's 3px rounded outline, an `alpha(background, 0.6)` fill matching
|
||||
`.qs-tile`, and the AGS border pair - `alpha(color4, 0.5)` while the field is
|
||||
empty, going to a solid `accent` once there is something in it, and `urgent` on
|
||||
a failed attempt. Only the geometry and the alphas are fixed; the colors come
|
||||
from the active theme, so the field follows a theme change the way the AGS
|
||||
widgets do. The `[lock]` tokens in a theme's `shell.toml` no longer reach the
|
||||
border or the fill. `Text.Fit` scales the branding into whatever room is left
|
||||
above the field, so a wider or taller `screensaver.txt` cannot run off the
|
||||
screen, and a missing file leaves the lock unbranded rather than broken.
|
||||
|
||||
Unlike the other clones this one is load-bearing for security. The lock is a
|
||||
`service` plugin, so it is enabled by its id appearing in `plugins[]` and the
|
||||
original is switched off through `disabledPlugins[]` - both are needed, because
|
||||
the two would otherwise register the same `lock` IPC target. There is no
|
||||
fallback: a QML error means the service never loads and `omarchy-shell lock
|
||||
lock` silently does nothing, which leaves the machine unlockable rather than
|
||||
locked open. `journalctl --user -t omarchy-shell` names the fault as
|
||||
`service plugin load failed for blob.lock`. To back out, drop `blob.lock` from
|
||||
`plugins[]` and `omarchy.lock` from `disabledPlugins[]`; `cloneSourceRestores`
|
||||
lists `blob.lock` so the shell restores the original by itself if the clone is
|
||||
removed through `omarchy plugin remove`.
|
||||
|
||||
Re-copy `LockView.qml` and `Service.qml` from
|
||||
`/usr/share/omarchy/shell/plugins/lock/` after an Omarchy update that touches
|
||||
the lock, then re-apply the two blocks above. `BrandingSource.qml` is wholly
|
||||
ours and carries over untouched.
|
||||
|
||||
`plugins/blob.bar/` replaces the whole bar so the clock cannot be dragged out
|
||||
of the center. Omarchy 4 puts a drag-to-reorder handler on every bar module and
|
||||
persists the drop into `bar.layout`; once `blob.clock` leaves the center list,
|
||||
`centerAnchor` matches nothing and the center renders as a plain group. The bar
|
||||
config has no setting for this, so the only lever is `canReorder` in `Bar.qml`.
|
||||
The lock itself is two lines: an `anchored` property on `ModuleSlot`, and
|
||||
`canReorder` gated on it, so only the module named by `centerAnchor` is pinned.
|
||||
Every other widget still drags.
|
||||
|
||||
Three more lines are needed just to make the file loadable outside the packaged
|
||||
slot. Stock `Bar.qml` declares `omarchyPath`, `barWidgetRegistry`, and
|
||||
`barConfig` as `required`, which only works for the built-in bar because the
|
||||
host instantiates it from an inline `Component` that sets them. A plugin bar is
|
||||
loaded by URL and configured in the loader's `onLoaded`, so the required
|
||||
properties are still unset at construction and the whole bar fails to build.
|
||||
The copy declares them as ordinary properties defaulting to `""`/`null`, and
|
||||
guards the one `barWidgetRegistry.widgets` read; `applyBarConfig` already falls
|
||||
back to an empty layout, so nothing renders until the host injects the real
|
||||
config a moment later.
|
||||
|
||||
When this happens the bar does not fall back to the stock one, it simply does
|
||||
not appear: the host's `Loader.Error` branch calls a nonexistent `errorString`,
|
||||
throws, and never sets `failedBarId`. If the bar ever vanishes after editing
|
||||
this plugin, that is the first thing to check - `journalctl --user` will name
|
||||
the offending property.
|
||||
|
||||
Do not run `omarchy plugin clone omarchy.bar` to refresh it. That command copies
|
||||
the whole directory, including `widgets/`, whose manifests re-declare
|
||||
`omarchy.workspaces`, `omarchy.tray`, and four more ids that already exist.
|
||||
`Bar.qml` needs only `BarModel.js` - its widgets come from the host registry -
|
||||
so the copy is just `manifest.json`, `Bar.qml`, and `BarModel.js`. A bar is
|
||||
selected by `bar.id` in `shell.json` rather than by the enabled-plugin list, and
|
||||
it has no disabled state: you leave one bar by naming another.
|
||||
|
||||
Re-copy after an Omarchy update if the upstream widget or bar gains something
|
||||
worth picking up: `omarchy plugin clone omarchy.workspaces` then re-apply the
|
||||
one-line `workspaceIds()` change, or copy `Bar.qml` and `BarModel.js` from
|
||||
`/usr/share/omarchy/shell/plugins/bar/` and re-apply the two `canReorder` lines.
|
||||
|
||||
## Editing
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
// Blob entries added to the Quickshell Omarchy menu (Super + Space).
|
||||
// These replace the walker/elephant menus used before Omarchy 4.
|
||||
|
||||
"blob": { "icon": "", "label": "Blob" },
|
||||
"blob.wallpaper": { "icon": "", "label": "Wallpaper", "action": "blob_wallpaper --menu", "description": "Pick a wallpaper from ~/wallpapers" },
|
||||
"blob.theme": { "icon": "", "label": "Theme", "action": "blob_theme --menu", "description": "Pick a local or Omarchy color theme" },
|
||||
"blob.dynamic": { "icon": "", "label": "Dynamic theme", "action": "blob_theme --dynamic", "description": "Recolor from the current wallpaper" },
|
||||
"blob.glass": { "icon": "", "label": "Toggle glass", "action": "blob_glass toggle", "checked": "test -f $HOME/.local/state/omarchy/toggles/hypr/blob-glass.lua" }
|
||||
"blob": { "icon": "", "label": "Blob", "before": "learn" },
|
||||
"blob.wallpaper": { "icon": "", "label": "Wallpaper", "action": "blob_wallpaper --menu", "description": "Pick a wallpaper from ~/wallpapers" },
|
||||
"blob.theme": { "icon": "", "label": "Theme", "action": "blob_theme --menu", "description": "Pick a local or Omarchy color theme" },
|
||||
"blob.dynamic": { "icon": "", "label": "Dynamic theme", "action": "blob_theme --dynamic", "description": "Recolor from the current wallpaper" },
|
||||
"blob.glass": { "icon": "", "label": "Toggle glass", "action": "blob_glass toggle", "checked": "test -f $HOME/.local/state/omarchy/toggles/hypr/blob-glass.lua" }
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,231 @@
|
||||
function isPlainObject(value) {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function normalizePosition(value) {
|
||||
var next = String(value || "").trim()
|
||||
return /^(top|bottom|left|right)$/.test(next) ? next : "top"
|
||||
}
|
||||
|
||||
function entrySettings(entry) {
|
||||
if (!isPlainObject(entry)) return {}
|
||||
var copy = {}
|
||||
for (var key in entry) {
|
||||
if (key === "id") continue
|
||||
copy[key] = entry[key]
|
||||
}
|
||||
return copy
|
||||
}
|
||||
|
||||
function entryId(entry) {
|
||||
if (typeof entry === "string") return entry
|
||||
if (isPlainObject(entry)) {
|
||||
var id = entry["id"]
|
||||
if (id !== undefined && id !== null && String(id) !== "") return String(id)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
function pinTrayToInner(entries, section) {
|
||||
var trayEntry = null
|
||||
var result = []
|
||||
var values = Array.isArray(entries) ? entries : []
|
||||
for (var i = 0; i < values.length; i++) {
|
||||
if (entryId(values[i]) === "omarchy.tray") trayEntry = values[i]
|
||||
else result.push(values[i])
|
||||
}
|
||||
if (trayEntry) {
|
||||
if (section === "right") result.unshift(trayEntry)
|
||||
else result.push(trayEntry)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function moduleString(entry, key, fallback) {
|
||||
var settings = entrySettings(entry)
|
||||
var value = settings[key]
|
||||
return value === undefined || value === null ? fallback : String(value)
|
||||
}
|
||||
|
||||
function entryIndex(entries, name) {
|
||||
if (!Array.isArray(entries)) return -1
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
if (entryId(entries[i]) === name) return i
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function entriesBefore(entries, name) {
|
||||
var index = entryIndex(entries, name)
|
||||
return index <= 0 ? [] : entries.slice(0, index)
|
||||
}
|
||||
|
||||
function entriesAfter(entries, name) {
|
||||
var index = entryIndex(entries, name)
|
||||
return index === -1 ? [] : entries.slice(index + 1)
|
||||
}
|
||||
|
||||
// A shell.json write that only changes inline widget settings (the battery
|
||||
// percentage toggle, a clock format change) must not rebuild the bar.
|
||||
// Compare two normalized layouts: when the structure is unchanged — same
|
||||
// entry ids in the same order per region — return the settings-only changes
|
||||
// as {region, index, entry}. Return null when the change is structural, or
|
||||
// touches an entry a live settings push cannot safely reach: custom modules
|
||||
// read their entry directly rather than an injected settings property, and
|
||||
// a duplicated id makes the push ambiguous.
|
||||
function inlineSettingsDelta(current, next) {
|
||||
if (!isPlainObject(current) || !isPlainObject(next)) return null
|
||||
var regions = ["left", "center", "right"]
|
||||
var counts = {}
|
||||
for (var r = 0; r < regions.length; r++) {
|
||||
var entries = Array.isArray(next[regions[r]]) ? next[regions[r]] : []
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
var id = entryId(entries[i])
|
||||
counts[id] = (counts[id] || 0) + 1
|
||||
}
|
||||
}
|
||||
var changes = []
|
||||
for (var s = 0; s < regions.length; s++) {
|
||||
var region = regions[s]
|
||||
var a = Array.isArray(current[region]) ? current[region] : []
|
||||
var b = Array.isArray(next[region]) ? next[region] : []
|
||||
if (a.length !== b.length) return null
|
||||
for (var j = 0; j < a.length; j++) {
|
||||
if (entryId(a[j]) !== entryId(b[j])) return null
|
||||
if (JSON.stringify(a[j]) === JSON.stringify(b[j])) continue
|
||||
if (customModuleType(a[j]) || customModuleType(b[j])) return null
|
||||
if (counts[entryId(b[j])] > 1) return null
|
||||
changes.push({ region: region, index: j, entry: b[j] })
|
||||
}
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
function expandPath(value, home) {
|
||||
var path = String(value || "")
|
||||
if (path === "") return ""
|
||||
if (path.indexOf("~/") === 0) return home + path.substring(1)
|
||||
if (path.indexOf("$HOME/") === 0) return home + path.substring(5)
|
||||
return path
|
||||
}
|
||||
|
||||
function customModuleSafeName(name) {
|
||||
var value = String(name || "")
|
||||
return value !== "" && value.indexOf("..") === -1 && value[0] !== "/"
|
||||
}
|
||||
|
||||
function customModuleType(entry) {
|
||||
var settings = entrySettings(entry)
|
||||
var type = String(settings.type || "")
|
||||
if (type) return type
|
||||
if (settings.exec) return "command"
|
||||
if (settings.source) return "qml"
|
||||
return ""
|
||||
}
|
||||
|
||||
function customModulePath(entry, home, configDir) {
|
||||
var settings = entrySettings(entry)
|
||||
var name = entryId(entry)
|
||||
var source = settings.source ? expandPath(settings.source, home) : ""
|
||||
if (!source && customModuleSafeName(name))
|
||||
source = String(configDir || "") + "/bar/modules/" + String(name) + ".qml"
|
||||
return source
|
||||
}
|
||||
|
||||
// A center module is mounted twice once an anchor is set: the copy that is
|
||||
// actually drawn, and a zero-size placeholder holding its place in the flow
|
||||
// beside the anchor. Panel routing has to pick the drawn one — it is the only
|
||||
// one that can anchor a popup, carry the open-panel mark, or be found again
|
||||
// by switchPanelFrom — and fall back to the placeholder only when nothing is
|
||||
// on screen. The order the two are registered in is not stable across a live
|
||||
// bar reconfiguration, so picking the first match is not good enough.
|
||||
function isDrawnSlot(slot) {
|
||||
return !!slot && slot.visible === true && slot.width > 0 && slot.height > 0
|
||||
}
|
||||
|
||||
function pickDrawnSlot(slots) {
|
||||
var placeholder = null
|
||||
var list = slots || []
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (!list[i]) continue
|
||||
if (isDrawnSlot(list[i])) return list[i]
|
||||
if (!placeholder) placeholder = list[i]
|
||||
}
|
||||
return placeholder
|
||||
}
|
||||
|
||||
// A bar surface is built per monitor, so a panel hotkey has several live
|
||||
// copies of the same widget to route to, and the panel opens on whichever
|
||||
// monitor's copy answers. Candidates are `{ slot, screenName, opened }`.
|
||||
//
|
||||
// An open copy wins first: hide and toggle have to reach the panel the user
|
||||
// can actually see, wherever it was opened from. Otherwise the focused
|
||||
// monitor's copy wins, so a summon lands where the user is working instead of
|
||||
// on whichever output registered its slot first. Neither narrowing applies on
|
||||
// a single monitor, or when the focused output has no bar of its own.
|
||||
function pickPanelSlot(candidates, focusedScreen) {
|
||||
var rows = Array.isArray(candidates) ? candidates : []
|
||||
var pool = rows.filter(function(row) { return row && row.opened === true })
|
||||
if (pool.length === 0) pool = rows.filter(function(row) { return !!row })
|
||||
|
||||
var focused = String(focusedScreen || "")
|
||||
if (focused) {
|
||||
var onFocused = pool.filter(function(row) { return row.screenName === focused })
|
||||
if (onFocused.length > 0) pool = onFocused
|
||||
}
|
||||
|
||||
return pickDrawnSlot(pool.map(function(row) { return row.slot }))
|
||||
}
|
||||
|
||||
// Resolve a pointer anywhere along the bar to the closest insertion edge.
|
||||
// Requiring the pointer to sit inside another widget makes the empty space
|
||||
// around a centered group a dead zone, even though it visually reads as the
|
||||
// most natural place to drop.
|
||||
function nearestDropTarget(candidates, point, vertical) {
|
||||
var rows = Array.isArray(candidates) ? candidates : []
|
||||
var axis = vertical ? Number(point && point.y) : Number(point && point.x)
|
||||
if (!isFinite(axis)) return null
|
||||
|
||||
var best = null
|
||||
var bestDistance = Infinity
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var row = rows[i]
|
||||
if (!row || !row.slot) continue
|
||||
|
||||
var start = Number(vertical ? row.y : row.x)
|
||||
var size = Number(vertical ? row.height : row.width)
|
||||
if (!isFinite(start) || !isFinite(size) || size <= 0) continue
|
||||
|
||||
var beforeDistance = Math.abs(axis - start)
|
||||
var afterDistance = Math.abs(axis - (start + size))
|
||||
var after = afterDistance < beforeDistance
|
||||
var distance = after ? afterDistance : beforeDistance
|
||||
if (distance < bestDistance) {
|
||||
best = { slot: row.slot, after: after }
|
||||
bestDistance = distance
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
isDrawnSlot: isDrawnSlot,
|
||||
pickDrawnSlot: pickDrawnSlot,
|
||||
pickPanelSlot: pickPanelSlot,
|
||||
nearestDropTarget: nearestDropTarget,
|
||||
normalizePosition: normalizePosition,
|
||||
entrySettings: entrySettings,
|
||||
entryId: entryId,
|
||||
pinTrayToInner: pinTrayToInner,
|
||||
moduleString: moduleString,
|
||||
entryIndex: entryIndex,
|
||||
entriesBefore: entriesBefore,
|
||||
entriesAfter: entriesAfter,
|
||||
inlineSettingsDelta: inlineSettingsDelta,
|
||||
expandPath: expandPath,
|
||||
customModuleSafeName: customModuleSafeName,
|
||||
customModuleType: customModuleType,
|
||||
customModulePath: customModulePath
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "blob.bar",
|
||||
"name": "My Bar",
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"description": "Status bar with widgets",
|
||||
"kinds": [
|
||||
"bar"
|
||||
],
|
||||
"entryPoints": {
|
||||
"bar": "Bar.qml"
|
||||
},
|
||||
"omarchy": {
|
||||
"clonedFrom": "omarchy.bar"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
readonly property string home: Quickshell.env("HOME")
|
||||
readonly property string brandingPath: home + "/.config/omarchy/branding/screensaver.txt"
|
||||
readonly property string palettePath: home + "/.local/state/omarchy/current/theme/colors.toml"
|
||||
|
||||
property string brandingText: ""
|
||||
property string paletteColor4: ""
|
||||
|
||||
function readPaletteColor4(raw) {
|
||||
var lines = String(raw || "").split("\n")
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var match = lines[i].match(/^\s*color4\s*=\s*["']?(#[0-9A-Fa-f]{6})/)
|
||||
if (match) return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
FileView {
|
||||
path: root.brandingPath
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onLoaded: root.brandingText = text()
|
||||
onLoadFailed: root.brandingText = ""
|
||||
onFileChanged: reload()
|
||||
}
|
||||
|
||||
FileView {
|
||||
path: root.palettePath
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onLoaded: root.paletteColor4 = root.readPaletteColor4(text())
|
||||
onLoadFailed: root.paletteColor4 = ""
|
||||
onFileChanged: reload()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import QtQuick
|
||||
import QtQuick.Effects
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property string backgroundPath: ""
|
||||
property int backgroundVersion: 0
|
||||
property string brandingText: ""
|
||||
property string paletteColor4: ""
|
||||
property bool fingerprintConfigured: false
|
||||
property bool authenticatingPassword: false
|
||||
property string failureMessage: ""
|
||||
property int failedAttempts: 0
|
||||
property bool inputEnabled: true
|
||||
property bool loadBackground: true
|
||||
property string passwordText: ""
|
||||
property bool syncingPasswordText: false
|
||||
|
||||
readonly property string placeholderText: "Enter Password"
|
||||
readonly property int fieldWidth: 381
|
||||
readonly property int fieldHeight: 67
|
||||
readonly property int outlineThickness: 2
|
||||
readonly property int fieldRadius: 0
|
||||
readonly property int fieldFontSize: Math.round(Style.font.heading * 1.125)
|
||||
readonly property int passwordDotFontSize: Math.round(Style.font.heading * 1.33)
|
||||
readonly property int passwordDotLetterSpacing: Math.round(Style.font.heading * 0.19)
|
||||
// Space to keep clear on each side of the field for the fingerprint icon
|
||||
// (icon width plus a gap) so the centered dots never run under it.
|
||||
readonly property real fingerprintReserve: fingerprintConfigured ? Math.round(fingerprintIcon.implicitWidth + 12) : 0
|
||||
// Shrink the dots to fit once the password outgrows the field, so every
|
||||
// keystroke stays visible — otherwise long passwords clip with no feedback.
|
||||
readonly property real passwordDotScale: dotMetrics.advanceWidth > 0
|
||||
? Math.min(1, (passwordInput.width - 4) / dotMetrics.advanceWidth)
|
||||
: 1
|
||||
readonly property int brandingGap: Style.space(48)
|
||||
readonly property int brandingMaxWidth: 1100
|
||||
readonly property int brandingMaxFontSize: Math.round(Style.font.heading * 1.5)
|
||||
|
||||
readonly property bool showPasswordCursor: inputEnabled && !authenticatingPassword && failureMessage.length === 0
|
||||
readonly property bool errorState: failureMessage.length > 0
|
||||
readonly property bool inputActive: passwordText.length > 0 || authenticatingPassword
|
||||
readonly property color inputRestingBorder: paletteColor4.length > 0 ? paletteColor4 : Color.accent
|
||||
readonly property color inputBorderColor: errorState
|
||||
? Color.urgent
|
||||
: (inputActive ? Color.accent : Util.alpha(root.inputRestingBorder, 0.5))
|
||||
readonly property color inputBackground: Util.alpha(Color.background, 0.6)
|
||||
readonly property var inputBorderSpec: Border.flat(root.inputBorderColor, root.outlineThickness)
|
||||
|
||||
signal submitPassword(string password)
|
||||
signal passwordTextEdited(string password)
|
||||
signal clearFailureRequested()
|
||||
signal wakeRequested()
|
||||
|
||||
// Cache-busts the lock background by appending `?v=`. Adding a query
|
||||
// string keeps Image's loader happy while forcing it to reload when the
|
||||
// user picks a new background mid-session.
|
||||
function fileUrl(path) {
|
||||
if (!path) return ""
|
||||
var encoded = String(path).split("/").map(encodeURIComponent).join("/")
|
||||
return "file://" + encoded + "?v=" + backgroundVersion
|
||||
}
|
||||
|
||||
function forcePasswordFocus() {
|
||||
passwordInput.forceActiveFocus()
|
||||
}
|
||||
|
||||
function clearPassword() {
|
||||
passwordTextEdited("")
|
||||
}
|
||||
|
||||
function syncPasswordText() {
|
||||
if (passwordInput.text === passwordText) return
|
||||
syncingPasswordText = true
|
||||
passwordInput.text = passwordText
|
||||
syncingPasswordText = false
|
||||
}
|
||||
|
||||
onPasswordTextChanged: syncPasswordText()
|
||||
onInputEnabledChanged: {
|
||||
if (inputEnabled) Qt.callLater(forcePasswordFocus)
|
||||
}
|
||||
Component.onCompleted: {
|
||||
syncPasswordText()
|
||||
if (inputEnabled) Qt.callLater(forcePasswordFocus)
|
||||
}
|
||||
|
||||
// Measures the masked password at full size; passwordDotScale compares this
|
||||
// against the field width to decide how far the dots must shrink to fit.
|
||||
TextMetrics {
|
||||
id: dotMetrics
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: root.passwordDotFontSize
|
||||
font.letterSpacing: root.passwordDotLetterSpacing
|
||||
text: "●".repeat(passwordInput.text.length)
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
color: Color.background
|
||||
|
||||
Image {
|
||||
id: wallpaper
|
||||
anchors.fill: parent
|
||||
source: root.loadBackground ? root.fileUrl(root.backgroundPath) : ""
|
||||
fillMode: Image.PreserveAspectCrop
|
||||
asynchronous: true
|
||||
cache: false
|
||||
sourceSize.width: width
|
||||
sourceSize.height: height
|
||||
}
|
||||
|
||||
MultiEffect {
|
||||
anchors.fill: wallpaper
|
||||
source: wallpaper
|
||||
autoPaddingEnabled: false
|
||||
blurEnabled: root.loadBackground && wallpaper.status === Image.Ready
|
||||
blur: 1.0
|
||||
blurMax: 128
|
||||
blurMultiplier: 1.25
|
||||
contrast: -0.08
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
onClicked: { root.wakeRequested(); root.forcePasswordFocus() }
|
||||
onPositionChanged: root.wakeRequested()
|
||||
}
|
||||
|
||||
Text {
|
||||
id: branding
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
anchors.bottom: inputField.top
|
||||
anchors.bottomMargin: root.brandingGap
|
||||
width: Math.min(parent.width * 0.86, root.brandingMaxWidth)
|
||||
height: Math.max(0, inputField.y - root.brandingGap * 2)
|
||||
visible: root.brandingText.length > 0 && height > 0
|
||||
text: root.brandingText
|
||||
textFormat: Text.PlainText
|
||||
color: Color.lock.text
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: root.brandingMaxFontSize
|
||||
minimumPixelSize: 4
|
||||
fontSizeMode: Text.Fit
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignBottom
|
||||
}
|
||||
|
||||
BorderSurface {
|
||||
id: inputField
|
||||
width: root.fieldWidth
|
||||
height: root.fieldHeight
|
||||
anchors.centerIn: parent
|
||||
color: root.inputBackground
|
||||
borderSpec: root.inputBorderSpec
|
||||
radius: root.fieldRadius
|
||||
clip: true
|
||||
|
||||
TextInput {
|
||||
id: passwordInput
|
||||
anchors.fill: parent
|
||||
anchors.topMargin: inputField.borderTop
|
||||
// Reserve the fingerprint icon's width on both sides so the centered
|
||||
// dots stay symmetric and never slide under the icon as they grow.
|
||||
anchors.rightMargin: inputField.borderRight + 18 + root.fingerprintReserve
|
||||
anchors.bottomMargin: inputField.borderBottom
|
||||
anchors.leftMargin: inputField.borderLeft + 18 + root.fingerprintReserve
|
||||
verticalAlignment: TextInput.AlignVCenter
|
||||
horizontalAlignment: TextInput.AlignHCenter
|
||||
activeFocusOnPress: true
|
||||
clip: true
|
||||
enabled: root.inputEnabled && !root.authenticatingPassword
|
||||
readOnly: root.authenticatingPassword
|
||||
echoMode: TextInput.Password
|
||||
passwordCharacter: "\u25CF"
|
||||
passwordMaskDelay: 0
|
||||
color: Color.lock.text
|
||||
selectionColor: Color.lock.selection
|
||||
selectedTextColor: Color.lock.text
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: text.length > 0 ? Math.max(1, Math.floor(root.passwordDotFontSize * root.passwordDotScale)) : root.fieldFontSize
|
||||
font.letterSpacing: text.length > 0 ? root.passwordDotLetterSpacing * root.passwordDotScale : 0
|
||||
cursorVisible: activeFocus && root.showPasswordCursor && text.length > 0
|
||||
cursorDelegate: Rectangle {
|
||||
width: 2
|
||||
color: Color.lock.text
|
||||
visible: passwordInput.cursorVisible
|
||||
}
|
||||
|
||||
onTextChanged: {
|
||||
if (!root.syncingPasswordText) root.passwordTextEdited(text)
|
||||
if (text.length > 0) {
|
||||
root.wakeRequested()
|
||||
}
|
||||
if (text.length > 0 && root.failureMessage.length > 0) root.clearFailureRequested()
|
||||
}
|
||||
|
||||
onAccepted: {
|
||||
var submitted = root.passwordText
|
||||
root.passwordTextEdited("")
|
||||
if (submitted.length > 0) root.submitPassword(submitted)
|
||||
}
|
||||
|
||||
Keys.onPressed: function(event) {
|
||||
root.wakeRequested()
|
||||
if (event.key === Qt.Key_Escape || (event.modifiers & Qt.ControlModifier && event.key === Qt.Key_U)) {
|
||||
root.passwordTextEdited("")
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.fill: passwordInput
|
||||
text: root.authenticatingPassword ? "Checking…" : (root.failureMessage.length > 0 ? root.failureMessage : root.placeholderText)
|
||||
visible: passwordInput.text.length === 0
|
||||
color: root.authenticatingPassword ? Color.lock.text : (root.failureMessage.length > 0 ? Color.lock.textError : Color.lock.placeholder)
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: root.fieldFontSize
|
||||
font.italic: !root.authenticatingPassword && root.failureMessage.length > 0
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
|
||||
// Fingerprint hint pinned inside the field's right edge when a sensor is
|
||||
// enrolled, so the user knows they can touch to unlock instead of typing.
|
||||
// Matches hyprlock, which draws its fingerprint icon in the same spot.
|
||||
Text {
|
||||
id: fingerprintIcon
|
||||
objectName: "fingerprintIndicator"
|
||||
anchors.right: parent.right
|
||||
anchors.rightMargin: inputField.borderRight + 18
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.fingerprintConfigured
|
||||
text: ""
|
||||
color: Color.lock.placeholder
|
||||
font.family: Style.font.family
|
||||
font.pixelSize: Math.round(root.fieldFontSize * 1.1)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pam
|
||||
import Quickshell.Wayland
|
||||
import qs.Commons
|
||||
|
||||
Item {
|
||||
id: root
|
||||
|
||||
property var shell: null
|
||||
property string omarchyPath: ""
|
||||
|
||||
readonly property string home: Quickshell.env("HOME")
|
||||
readonly property string stateHome: home + "/.local/state"
|
||||
readonly property string userName: Quickshell.env("USER") || Quickshell.env("LOGNAME")
|
||||
readonly property string currentBackgroundLink: stateHome + "/omarchy/current/background"
|
||||
|
||||
property bool lockRequested: false
|
||||
property bool pendingSessionLock: false
|
||||
property bool authenticatingPassword: false
|
||||
property bool fingerprintAuthenticating: false
|
||||
property bool passwordPamConfigured: false
|
||||
property bool fingerprintConfigured: false
|
||||
property bool previewVisible: false
|
||||
property string enteredPassword: ""
|
||||
property string pendingPassword: ""
|
||||
property string failureMessage: ""
|
||||
property int failedAttempts: 0
|
||||
property string backgroundPath: ""
|
||||
property int backgroundVersion: 0
|
||||
property string lastEvent: "init"
|
||||
property string lastEventAt: ""
|
||||
property bool strandedLock: false
|
||||
property bool strandedLockResolved: false
|
||||
|
||||
readonly property bool locked: lockRequested || sessionLock.locked || sessionLock.secure
|
||||
readonly property bool authenticating: authenticatingPassword || fingerprintAuthenticating
|
||||
|
||||
function realScreenCount() {
|
||||
var screens = Quickshell.screens || []
|
||||
var count = 0
|
||||
|
||||
for (var i = 0; i < screens.length; i++) {
|
||||
var screen = screens[i]
|
||||
if (screen && screen.name && screen.width > 0 && screen.height > 0) count += 1
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
function hasRealScreen() {
|
||||
return realScreenCount() > 0
|
||||
}
|
||||
|
||||
function queueSessionLock() {
|
||||
pendingSessionLock = true
|
||||
if (!sessionLockStabilizeTimer.running) logEvent("lock-pending: screen-stabilizing")
|
||||
sessionLockStabilizeTimer.restart()
|
||||
if (!pendingSessionLockTimer.running) pendingSessionLockTimer.start()
|
||||
}
|
||||
|
||||
function requestSessionLock() {
|
||||
if (!lockRequested || sessionLock.locked || sessionLock.secure) return
|
||||
if (sessionLockStabilizeTimer.running) return
|
||||
|
||||
if (!hasRealScreen()) {
|
||||
if (!pendingSessionLock || lastEvent !== "lock-pending: no-real-screen") logEvent("lock-pending: no-real-screen")
|
||||
pendingSessionLock = true
|
||||
if (!pendingSessionLockTimer.running) pendingSessionLockTimer.start()
|
||||
return
|
||||
}
|
||||
|
||||
pendingSessionLock = false
|
||||
pendingSessionLockTimer.stop()
|
||||
sessionLock.locked = true
|
||||
}
|
||||
|
||||
// ext-session-lock outlives its client, and a restart carries no lock over, so
|
||||
// a session locked this early is an orphan behind Hyprland's failsafe. Outputs
|
||||
// are often still absent here, so ask until the answer means something.
|
||||
function checkStrandedLock() {
|
||||
if (strandedLockResolved || strandedLockCheckProc.running) return
|
||||
|
||||
// A lock this shell took is nobody's orphan.
|
||||
if (locked || lockRequested) {
|
||||
strandedLockResolved = true
|
||||
return
|
||||
}
|
||||
|
||||
strandedLockCheckProc.running = true
|
||||
}
|
||||
|
||||
function recoverStrandedLock() {
|
||||
if (!strandedLock || locked || !passwordPamConfigured) return
|
||||
|
||||
strandedLock = false
|
||||
logEvent("lock-stranded: recovering")
|
||||
beginLock()
|
||||
}
|
||||
|
||||
function refreshBackground() {
|
||||
if (!readlinkProc.running) readlinkProc.running = true
|
||||
}
|
||||
|
||||
function refreshFingerprintStatus() {
|
||||
if (!fingerprintCheckProc.running) fingerprintCheckProc.running = true
|
||||
}
|
||||
|
||||
function logEvent(event) {
|
||||
lastEvent = event
|
||||
lastEventAt = new Date().toISOString()
|
||||
console.log("omarchy lock " + lastEventAt + " " + event)
|
||||
}
|
||||
|
||||
function resetAuthenticationState() {
|
||||
enteredPassword = ""
|
||||
pendingPassword = ""
|
||||
failureMessage = ""
|
||||
failedAttempts = 0
|
||||
authenticatingPassword = false
|
||||
fingerprintAuthenticating = false
|
||||
fingerprintRetryTimer.stop()
|
||||
if (passwordPam.active) passwordPam.abort()
|
||||
if (fingerprintPam.active) fingerprintPam.abort()
|
||||
}
|
||||
|
||||
function beginLock() {
|
||||
if (!passwordPamConfigured) {
|
||||
logEvent("lock-denied: missing-pam")
|
||||
return false
|
||||
}
|
||||
|
||||
resetAuthenticationState()
|
||||
lockRequested = true
|
||||
armBlankTimer()
|
||||
logEvent("lock-requested")
|
||||
queueSessionLock()
|
||||
|
||||
Qt.callLater(function() {
|
||||
root.refreshBackground()
|
||||
root.refreshFingerprintStatus()
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function finishUnlock() {
|
||||
if (!root.locked && !lockRequested) return
|
||||
|
||||
lockRequested = false
|
||||
pendingSessionLock = false
|
||||
sessionLockStabilizeTimer.stop()
|
||||
pendingSessionLockTimer.stop()
|
||||
resetAuthenticationState()
|
||||
idleBlankTimer.stop()
|
||||
sessionLock.locked = false
|
||||
logEvent("unlocked")
|
||||
runWake()
|
||||
}
|
||||
|
||||
function armBlankTimer() {
|
||||
idleBlankTimer.armedAt = Date.now()
|
||||
idleBlankTimer.restart()
|
||||
}
|
||||
|
||||
function runWake() {
|
||||
if (!wakeProcess.running) wakeProcess.running = true
|
||||
if (lockRequested) armBlankTimer()
|
||||
}
|
||||
|
||||
function runBlank() {
|
||||
if (!blankProcess.running) blankProcess.running = true
|
||||
}
|
||||
|
||||
function submitPassword(value) {
|
||||
var password = String(value || "")
|
||||
if (!lockRequested || authenticatingPassword || password.length === 0) return
|
||||
|
||||
runWake()
|
||||
pendingPassword = password
|
||||
failureMessage = ""
|
||||
authenticatingPassword = true
|
||||
|
||||
if (!passwordPam.start()) {
|
||||
handlePasswordFailure()
|
||||
return
|
||||
}
|
||||
|
||||
Qt.callLater(respondToPasswordPrompt)
|
||||
}
|
||||
|
||||
function respondToPasswordPrompt() {
|
||||
if (!authenticatingPassword || !passwordPam.active || !passwordPam.responseRequired) return
|
||||
passwordPam.respond(pendingPassword)
|
||||
}
|
||||
|
||||
function handlePasswordFailure() {
|
||||
if (!lockRequested) return
|
||||
|
||||
authenticatingPassword = false
|
||||
enteredPassword = ""
|
||||
pendingPassword = ""
|
||||
failedAttempts += 1
|
||||
failureMessage = "Authentication failed (" + failedAttempts + ")"
|
||||
runWake()
|
||||
}
|
||||
|
||||
function startFingerprint() {
|
||||
if (!lockRequested || !sessionLock.secure || !fingerprintConfigured) return
|
||||
if (fingerprintPam.active || fingerprintAuthenticating) return
|
||||
|
||||
fingerprintAuthenticating = true
|
||||
if (!fingerprintPam.start()) {
|
||||
fingerprintAuthenticating = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleFingerprintFinished(result) {
|
||||
fingerprintAuthenticating = false
|
||||
|
||||
if (!lockRequested) return
|
||||
if (result === PamResult.Success) {
|
||||
finishUnlock()
|
||||
} else if (fingerprintConfigured) {
|
||||
fingerprintRetryTimer.restart()
|
||||
}
|
||||
}
|
||||
|
||||
WlSessionLock {
|
||||
id: sessionLock
|
||||
|
||||
locked: false
|
||||
|
||||
onSecureStateChanged: {
|
||||
root.logEvent("secure=" + secure)
|
||||
if (secure) {
|
||||
root.pendingSessionLock = false
|
||||
sessionLockStabilizeTimer.stop()
|
||||
pendingSessionLockTimer.stop()
|
||||
root.startFingerprint()
|
||||
}
|
||||
}
|
||||
|
||||
onLockStateChanged: {
|
||||
root.logEvent("session-locked=" + locked)
|
||||
|
||||
if (locked) {
|
||||
root.pendingSessionLock = false
|
||||
sessionLockStabilizeTimer.stop()
|
||||
pendingSessionLockTimer.stop()
|
||||
}
|
||||
|
||||
if (!locked && root.lockRequested) {
|
||||
root.lockRequested = false
|
||||
root.pendingSessionLock = false
|
||||
sessionLockStabilizeTimer.stop()
|
||||
pendingSessionLockTimer.stop()
|
||||
root.resetAuthenticationState()
|
||||
root.runWake()
|
||||
}
|
||||
}
|
||||
|
||||
WlSessionLockSurface {
|
||||
id: lockSurface
|
||||
color: Color.background
|
||||
|
||||
LockView {
|
||||
id: lockView
|
||||
anchors.fill: parent
|
||||
backgroundPath: root.backgroundPath
|
||||
backgroundVersion: root.backgroundVersion
|
||||
brandingText: brandingSource.brandingText
|
||||
paletteColor4: brandingSource.paletteColor4
|
||||
fingerprintConfigured: root.fingerprintConfigured
|
||||
authenticatingPassword: root.authenticatingPassword
|
||||
failureMessage: root.failureMessage
|
||||
failedAttempts: root.failedAttempts
|
||||
inputEnabled: root.lockRequested
|
||||
loadBackground: root.locked
|
||||
passwordText: root.enteredPassword
|
||||
onPasswordTextEdited: function(password) { root.enteredPassword = password }
|
||||
onSubmitPassword: function(password) { root.submitPassword(password) }
|
||||
onClearFailureRequested: root.failureMessage = ""
|
||||
onWakeRequested: root.runWake()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
PanelWindow {
|
||||
id: previewWindow
|
||||
visible: root.previewVisible
|
||||
anchors { top: true; bottom: true; left: true; right: true }
|
||||
color: "transparent"
|
||||
WlrLayershell.namespace: "omarchy-lock-preview"
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
|
||||
exclusionMode: ExclusionMode.Ignore
|
||||
|
||||
LockView {
|
||||
anchors.fill: parent
|
||||
backgroundPath: root.backgroundPath
|
||||
backgroundVersion: root.backgroundVersion
|
||||
brandingText: brandingSource.brandingText
|
||||
paletteColor4: brandingSource.paletteColor4
|
||||
fingerprintConfigured: root.fingerprintConfigured
|
||||
authenticatingPassword: false
|
||||
failureMessage: ""
|
||||
failedAttempts: 0
|
||||
inputEnabled: false
|
||||
loadBackground: root.previewVisible
|
||||
passwordText: ""
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
||||
onClicked: root.previewVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
PamContext {
|
||||
id: passwordPam
|
||||
config: "omarchy-lock-password"
|
||||
user: root.userName
|
||||
|
||||
onResponseRequiredChanged: root.respondToPasswordPrompt()
|
||||
onPamMessage: root.respondToPasswordPrompt()
|
||||
|
||||
onCompleted: function(result) {
|
||||
root.authenticatingPassword = false
|
||||
root.pendingPassword = ""
|
||||
|
||||
if (!root.lockRequested) return
|
||||
if (result === PamResult.Success) root.finishUnlock()
|
||||
else root.handlePasswordFailure()
|
||||
}
|
||||
|
||||
onError: function(error) {
|
||||
root.handlePasswordFailure()
|
||||
}
|
||||
}
|
||||
|
||||
PamContext {
|
||||
id: fingerprintPam
|
||||
config: "omarchy-lock-fingerprint"
|
||||
user: root.userName
|
||||
|
||||
onCompleted: function(result) {
|
||||
root.handleFingerprintFinished(result)
|
||||
}
|
||||
|
||||
onError: function(error) {
|
||||
root.fingerprintAuthenticating = false
|
||||
if (root.lockRequested && root.fingerprintConfigured) fingerprintRetryTimer.restart()
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: fingerprintRetryTimer
|
||||
interval: 250
|
||||
repeat: false
|
||||
onTriggered: root.startFingerprint()
|
||||
}
|
||||
|
||||
Process {
|
||||
id: readlinkProc
|
||||
command: ["readlink", "-f", root.currentBackgroundLink]
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: {
|
||||
var next = String(text || "").trim()
|
||||
if (next !== root.backgroundPath) {
|
||||
root.backgroundPath = next
|
||||
root.backgroundVersion += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: fingerprintCheckProc
|
||||
command: ["bash", "-c", "if [[ -f /etc/pam.d/omarchy-lock-fingerprint ]] && command -v fprintd-list >/dev/null 2>&1 && fprintd-list \"$USER\" 2>/dev/null | grep -qi finger; then echo yes; else echo no; fi"]
|
||||
stdout: StdioCollector { id: fingerprintCheckStdout; waitForEnd: true }
|
||||
onExited: {
|
||||
root.fingerprintConfigured = String(fingerprintCheckStdout.text || "").trim() === "yes"
|
||||
if (root.lockRequested && root.fingerprintConfigured) root.startFingerprint()
|
||||
else if (!root.fingerprintConfigured && fingerprintPam.active) fingerprintPam.abort()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: strandedLockCheckProc
|
||||
command: ["bash", "-c", "omarchy-hyprland-session-locked"]
|
||||
onExited: function(exitCode) {
|
||||
// No output to read the lock off yet.
|
||||
if (exitCode === 2) return
|
||||
|
||||
root.strandedLockResolved = true
|
||||
|
||||
// A lock taken while this was in flight is this shell's own.
|
||||
root.strandedLock = exitCode === 0 && !root.locked && !root.lockRequested
|
||||
root.recoverStrandedLock()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: wakeProcess
|
||||
command: ["bash", "-c", "omarchy-system-wake"]
|
||||
}
|
||||
|
||||
Process {
|
||||
id: blankProcess
|
||||
command: ["bash", "-c", "omarchy-brightness-keyboard off; omarchy-brightness-display off"]
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: idleBlankTimer
|
||||
interval: 5000
|
||||
repeat: false
|
||||
property double armedAt: 0
|
||||
onTriggered: {
|
||||
// A countdown frozen by suspend fires right after resume, which would
|
||||
// blank the freshly woken unlock screen under the user. Wall-clock time
|
||||
// exposes the gap: take a fresh run-up instead of blanking.
|
||||
if (Date.now() - armedAt > interval + 2000) {
|
||||
root.armBlankTimer()
|
||||
return
|
||||
}
|
||||
// Only a password check in flight should hold the display up. The
|
||||
// fingerprint PAM stays armed for the whole lock, so gating on
|
||||
// `authenticating` here would keep the panel lit until unlock.
|
||||
if (root.lockRequested && !root.authenticatingPassword) root.runBlank()
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: sessionLockStabilizeTimer
|
||||
interval: 500
|
||||
repeat: false
|
||||
onTriggered: root.requestSessionLock()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: pendingSessionLockTimer
|
||||
interval: 100
|
||||
repeat: true
|
||||
onTriggered: root.requestSessionLock()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: strandedLockRetryTimer
|
||||
interval: 500
|
||||
repeat: true
|
||||
// Covers the compositor settling; screens coming back re-arm it.
|
||||
readonly property int budget: 20
|
||||
property int remaining: 20
|
||||
running: !root.strandedLockResolved && remaining > 0
|
||||
|
||||
function rearm() {
|
||||
if (!root.strandedLockResolved) remaining = budget
|
||||
}
|
||||
|
||||
onTriggered: {
|
||||
remaining -= 1
|
||||
root.checkStrandedLock()
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Quickshell
|
||||
function onScreensChanged() {
|
||||
root.requestSessionLock()
|
||||
|
||||
// A monitor still coming up has no workspace, so cannot answer yet.
|
||||
strandedLockRetryTimer.rearm()
|
||||
root.checkStrandedLock()
|
||||
}
|
||||
}
|
||||
|
||||
onAuthenticatingPasswordChanged: {
|
||||
if (!lockRequested) return
|
||||
if (authenticatingPassword) idleBlankTimer.stop()
|
||||
else armBlankTimer()
|
||||
}
|
||||
|
||||
BrandingSource {
|
||||
id: brandingSource
|
||||
}
|
||||
|
||||
FileView {
|
||||
path: "/etc/pam.d/omarchy-lock-password"
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onLoaded: root.passwordPamConfigured = true
|
||||
onLoadFailed: root.passwordPamConfigured = false
|
||||
onFileChanged: reload()
|
||||
}
|
||||
|
||||
// No lock before PAM is known good. An answer from before then may be stale --
|
||||
// the failsafe can be cleared from a TTY -- so re-ask rather than act on it.
|
||||
onPasswordPamConfiguredChanged: {
|
||||
if (!passwordPamConfigured) return
|
||||
|
||||
strandedLock = false
|
||||
strandedLockResolved = false
|
||||
strandedLockRetryTimer.rearm()
|
||||
checkStrandedLock()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
refreshBackground()
|
||||
refreshFingerprintStatus()
|
||||
checkStrandedLock()
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "lock"
|
||||
|
||||
function lock(): string {
|
||||
if (!root.passwordPamConfigured) return "missing-pam"
|
||||
if (!root.locked && !root.beginLock()) return "failed"
|
||||
return "ok"
|
||||
}
|
||||
|
||||
function isLocked(): string {
|
||||
return root.locked ? "true" : "false"
|
||||
}
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
locked: root.locked,
|
||||
requested: root.lockRequested,
|
||||
pending: root.pendingSessionLock,
|
||||
sessionLocked: sessionLock.locked,
|
||||
secure: sessionLock.secure,
|
||||
realScreens: root.realScreenCount(),
|
||||
passwordPam: root.passwordPamConfigured,
|
||||
fingerprint: root.fingerprintConfigured,
|
||||
authenticating: root.authenticating,
|
||||
lastEvent: root.lastEvent,
|
||||
lastEventAt: root.lastEventAt
|
||||
})
|
||||
}
|
||||
|
||||
function preview(): string {
|
||||
root.refreshBackground()
|
||||
root.refreshFingerprintStatus()
|
||||
root.previewVisible = true
|
||||
return "ok"
|
||||
}
|
||||
|
||||
function hidePreview(): string {
|
||||
root.previewVisible = false
|
||||
return "ok"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "blob.lock",
|
||||
"name": "My lock screen",
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"description": "Quickshell session lock with separate password and fingerprint PAM flows.",
|
||||
"kinds": [
|
||||
"service"
|
||||
],
|
||||
"keepLoaded": true,
|
||||
"entryPoints": {
|
||||
"service": "Service.qml"
|
||||
},
|
||||
"omarchy": {
|
||||
"clonedFrom": "omarchy.lock"
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,67 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.Commons
|
||||
import qs.Ui
|
||||
|
||||
BarWidget {
|
||||
id: root
|
||||
moduleName: "omarchy.menu"
|
||||
|
||||
readonly property string iconPath: Quickshell.env("HOME") + "/.config/omarchy/branding/blob_icon.svg"
|
||||
readonly property int iconSize: Math.round(Style.font.body * 1.35)
|
||||
|
||||
property string iconSvg: ""
|
||||
|
||||
readonly property string iconColor: hexColor(button.foreground)
|
||||
readonly property string tintedSvg: iconSvg.replace(/fill="#000000"/g, 'fill="' + iconColor + '"')
|
||||
readonly property string iconUrl: iconSvg.length > 0 ? "data:image/svg+xml;base64," + Qt.btoa(tintedSvg) : ""
|
||||
readonly property bool iconReady: icon.status === Image.Ready
|
||||
|
||||
function hexColor(value) {
|
||||
function channel(fraction) {
|
||||
return ("0" + Math.round(fraction * 255).toString(16)).slice(-2)
|
||||
}
|
||||
return "#" + channel(value.r) + channel(value.g) + channel(value.b)
|
||||
}
|
||||
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
FileView {
|
||||
path: root.iconPath
|
||||
watchChanges: true
|
||||
printErrors: false
|
||||
onLoaded: root.iconSvg = text()
|
||||
onLoadFailed: root.iconSvg = ""
|
||||
onFileChanged: reload()
|
||||
}
|
||||
|
||||
WidgetButton {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: "\ue900"
|
||||
fontFamily: "omarchy"
|
||||
labelVisible: !root.iconReady
|
||||
fixedWidth: root.iconReady ? root.iconSize + Style.spaceReal(15) : -1
|
||||
horizontalMargin: 7.5
|
||||
onPressed: function(button) {
|
||||
if (!root.bar) return
|
||||
if (button === Qt.RightButton) root.bar.run("xdg-terminal-exec")
|
||||
else root.bar.run("omarchy-shell shell toggle omarchy.menu '{\"menu\":\"root\"}'")
|
||||
}
|
||||
|
||||
Image {
|
||||
id: icon
|
||||
anchors.centerIn: parent
|
||||
width: root.iconSize
|
||||
height: root.iconSize
|
||||
source: root.iconUrl
|
||||
sourceSize.width: root.iconSize * 2
|
||||
sourceSize.height: root.iconSize * 2
|
||||
smooth: true
|
||||
visible: root.iconReady
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ function normalizeItem(id, raw) {
|
||||
return {
|
||||
id: id,
|
||||
parent: parent,
|
||||
before: value.before || "",
|
||||
kind: kind,
|
||||
icon: value.icon || "",
|
||||
iconFont: value.iconFont || "",
|
||||
@@ -62,6 +63,23 @@ function parseMenuJsonc(raw) {
|
||||
return out
|
||||
}
|
||||
|
||||
function applyBeforeHints(items, itemOrder) {
|
||||
var order = itemOrder.slice()
|
||||
|
||||
for (var i = 0; i < itemOrder.length; i++) {
|
||||
var entry = items[itemOrder[i]]
|
||||
if (!entry || !entry.before) continue
|
||||
|
||||
var entryIndex = order.indexOf(entry.id)
|
||||
if (entryIndex < 0 || order.indexOf(entry.before) < 0) continue
|
||||
|
||||
order.splice(entryIndex, 1)
|
||||
order.splice(order.indexOf(entry.before), 0, entry.id)
|
||||
}
|
||||
|
||||
return order
|
||||
}
|
||||
|
||||
function mergeMenuSources(defaultItems, userItems) {
|
||||
var nextItems = ({})
|
||||
var nextOrder = []
|
||||
@@ -82,8 +100,10 @@ function mergeMenuSources(defaultItems, userItems) {
|
||||
}
|
||||
}
|
||||
|
||||
nextOrder = applyBeforeHints(nextItems, nextOrder)
|
||||
|
||||
if (!nextItems.root) {
|
||||
nextItems.root = { id: "root", parent: "", kind: "menu", icon: "", iconFont: "", label: "Go", title: "", target: "", description: "", aliases: [], when: "", checked: "", action: "", provider: "" }
|
||||
nextItems.root = { id: "root", parent: "", before: "", kind: "menu", icon: "", iconFont: "", label: "Go", title: "", target: "", description: "", aliases: [], when: "", checked: "", action: "", provider: "" }
|
||||
nextOrder.unshift("root")
|
||||
}
|
||||
for (var k3 = 0; k3 < nextOrder.length; k3++) nextItems[nextOrder[k3]].order = k3
|
||||
|
||||
+11
-13
@@ -2,9 +2,10 @@
|
||||
"version": 1,
|
||||
"idle": {
|
||||
"screensaver": 300,
|
||||
"lock": 301
|
||||
"lock": 900
|
||||
},
|
||||
"bar": {
|
||||
"id": "blob.bar",
|
||||
"position": "top",
|
||||
"transparent": false,
|
||||
"centerAnchor": "blob.clock",
|
||||
@@ -50,15 +51,6 @@
|
||||
{
|
||||
"id": "omarchy.monitor"
|
||||
},
|
||||
{
|
||||
"id": "blob.cpu",
|
||||
"type": "command",
|
||||
"text": "",
|
||||
"tooltip": "System monitor",
|
||||
"onClick": "omarchy-launch-or-focus-tui btop",
|
||||
"onMiddleClick": "alacritty",
|
||||
"onRightClick": "ags toggle sys-monitor"
|
||||
},
|
||||
{
|
||||
"id": "omarchy.power"
|
||||
},
|
||||
@@ -73,11 +65,17 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"plugins": [],
|
||||
"plugins": [
|
||||
{
|
||||
"id": "blob.lock"
|
||||
}
|
||||
],
|
||||
"disabledPlugins": [
|
||||
"omarchy.menu"
|
||||
"omarchy.menu",
|
||||
"omarchy.lock"
|
||||
],
|
||||
"cloneSourceRestores": [
|
||||
"blob.menu"
|
||||
"blob.menu",
|
||||
"blob.lock"
|
||||
]
|
||||
}
|
||||
|
||||
+631
-19
@@ -1,28 +1,640 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# blob_boot.sh - boot splash and bootloader management.
|
||||
#
|
||||
# blob_boot.sh [image] Apply a Plymouth boot splash image (default action)
|
||||
# blob_boot.sh grub Migrate from Limine back to GRUB, detecting every OS
|
||||
# --purge removes Limine in the same run instead of
|
||||
# keeping it for one fallback boot
|
||||
# blob_boot.sh detect Rescan for other operating systems, rebuild the menu
|
||||
# blob_boot.sh cleanup Retire Limine once GRUB has been confirmed working
|
||||
# blob_boot.sh status Show what this machine currently boots with
|
||||
#
|
||||
# This machine has a 256 MB EFI System Partition, which is why Omarchy switched
|
||||
# it to a single unified kernel image. Going back to GRUB means going back to a
|
||||
# separate kernel and initramfs, so every step here is careful about space.
|
||||
|
||||
# Default to the branding image if no argument is provided
|
||||
if [ -z "$1" ]; then
|
||||
IMAGE_PATH="$HOME/Documents/dotfiles/branding/boot_flash.png"
|
||||
else
|
||||
IMAGE_PATH=$(realpath "$1")
|
||||
fi
|
||||
set -euo pipefail
|
||||
|
||||
if [ ! -f "$IMAGE_PATH" ]; then
|
||||
echo "Error: File '$IMAGE_PATH' does not exist."
|
||||
exit 1
|
||||
fi
|
||||
DEFAULT_IMAGE="$HOME/Documents/dotfiles/branding/boot_flash.png"
|
||||
PLYMOUTH_LOGO="/usr/share/plymouth/themes/omarchy/logo.png"
|
||||
|
||||
echo "Applying boot splash image: $IMAGE_PATH"
|
||||
log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
|
||||
info() { printf ' %s\n' "$*"; }
|
||||
warn() { printf '\033[1;33m %s\033[0m\n' "$*"; }
|
||||
ok() { printf '\033[1;32m==> %s\033[0m\n' "$*"; }
|
||||
die() { printf '\n\033[1;31m!!! %s\033[0m\n' "$*" >&2; exit 1; }
|
||||
|
||||
require_sudo() {
|
||||
echo "This requires sudo privileges."
|
||||
sudo -v || die "Could not obtain sudo."
|
||||
}
|
||||
|
||||
# Copy the image to the Plymouth theme directory
|
||||
sudo cp "$IMAGE_PATH" /usr/share/plymouth/themes/omarchy/logo.png
|
||||
# --------------------------------------------------------------------------
|
||||
# Boot splash
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# Ensure the correct permissions
|
||||
sudo chmod 644 /usr/share/plymouth/themes/omarchy/logo.png
|
||||
apply_splash() {
|
||||
local image_path
|
||||
if [ -z "${1:-}" ]; then
|
||||
image_path="$DEFAULT_IMAGE"
|
||||
else
|
||||
image_path=$(realpath "$1")
|
||||
fi
|
||||
|
||||
echo "Rebuilding initramfs..."
|
||||
# Exclusively use mkinitcpio for GRUB compatibility
|
||||
sudo mkinitcpio -P
|
||||
[ -f "$image_path" ] || die "File '$image_path' does not exist."
|
||||
[ -d "$(dirname "$PLYMOUTH_LOGO")" ] || die "Plymouth omarchy theme is not installed."
|
||||
|
||||
echo "Boot splash successfully updated!"
|
||||
log "Applying boot splash image: $image_path"
|
||||
require_sudo
|
||||
|
||||
sudo cp "$image_path" "$PLYMOUTH_LOGO"
|
||||
sudo chmod 644 "$PLYMOUTH_LOGO"
|
||||
|
||||
log "Rebuilding initramfs"
|
||||
rebuild_initramfs
|
||||
|
||||
# Under GRUB the menu references the initramfs by path, so it only needs
|
||||
# regenerating if the file set changed - but it is cheap and keeps the menu
|
||||
# honest after a kernel or os-prober change.
|
||||
if using_grub; then
|
||||
log "Refreshing the GRUB menu"
|
||||
sudo grub-mkconfig -o /boot/grub/grub.cfg
|
||||
fi
|
||||
|
||||
ok "Boot splash successfully updated!"
|
||||
}
|
||||
|
||||
# Call mkinitcpio directly: limine-mkinitcpio-hook installs a wrapper at
|
||||
# /usr/local/bin/mkinitcpio that stops to ask whether to rebuild Limine
|
||||
# entries, which is useless in a script. While Limine is still the active
|
||||
# bootloader its own tool is the one that actually updates what boots.
|
||||
rebuild_initramfs() {
|
||||
if command -v limine-mkinitcpio >/dev/null && ! using_grub; then
|
||||
info "Limine is still the bootloader; rebuilding through limine-mkinitcpio"
|
||||
sudo limine-mkinitcpio
|
||||
else
|
||||
sudo /usr/bin/mkinitcpio -P
|
||||
fi
|
||||
}
|
||||
|
||||
using_grub() {
|
||||
[ -f /boot/grub/grub.cfg ] && [ -f /boot/EFI/GRUB/grubx64.efi ]
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Limine -> GRUB migration
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
migrate_to_grub() {
|
||||
local purge=0
|
||||
[ "${1:-}" = "--purge" ] && purge=1
|
||||
|
||||
[ -d /sys/firmware/efi ] || die "Not booted in UEFI mode; this script assumes UEFI."
|
||||
mountpoint -q /boot || die "/boot is not mounted."
|
||||
command -v grub-install >/dev/null || die "The 'grub' package is not installed."
|
||||
command -v os-prober >/dev/null || die "The 'os-prober' package is not installed."
|
||||
|
||||
require_sudo
|
||||
|
||||
local machine_id esp_uuid backup
|
||||
machine_id=$(</etc/machine-id)
|
||||
esp_uuid=$(findmnt -no UUID /boot)
|
||||
backup=/root/limine-to-grub-$(date +%Y%m%d-%H%M%S)
|
||||
|
||||
log "Backing up the current boot configuration to $backup"
|
||||
sudo mkdir -p "$backup"
|
||||
for f in /etc/default/grub /etc/mkinitcpio.d/linux.preset /etc/mkinitcpio.conf \
|
||||
/boot/limine.conf /etc/mkinitcpio.conf.d /etc/grub.d; do
|
||||
[ -e "$f" ] && sudo cp -a "$f" "$backup/" 2>/dev/null || true
|
||||
done
|
||||
sudo efibootmgr -v | sudo tee "$backup/efibootmgr-before.txt" >/dev/null 2>&1 || true
|
||||
info "saved."
|
||||
|
||||
reclaim_esp_space "$machine_id" "$backup"
|
||||
restore_standard_initramfs
|
||||
configure_grub_scripts
|
||||
install_grub "$esp_uuid"
|
||||
fix_failing_boot_units
|
||||
|
||||
if (( purge )); then
|
||||
purge_limine
|
||||
set_boot_order
|
||||
enable_fallback_initramfs
|
||||
generate_menu
|
||||
else
|
||||
# Test GRUB with a one-shot BootNext rather than reordering BootOrder.
|
||||
# If GRUB fails to boot, a power cycle falls straight back to Limine on
|
||||
# its own - no boot-menu keypress, no timing, nothing to get right while
|
||||
# staring at a broken screen.
|
||||
set_boot_next
|
||||
fi
|
||||
|
||||
log "Result"
|
||||
df -h /boot | tail -1 | sed 's/^/ /'
|
||||
echo
|
||||
info "GRUB menu entries:"
|
||||
list_menu_entries
|
||||
echo
|
||||
if (( purge )); then
|
||||
ok "GRUB is the only bootloader. Limine is gone and its space is reclaimed."
|
||||
else
|
||||
ok "GRUB is installed and set to boot ONCE on the next restart."
|
||||
info "If it works: run '$0 cleanup' to delete Limine and reclaim its 51 MB."
|
||||
info "If it fails: hold the power button, then power on - the firmware falls"
|
||||
info " back to Limine by itself. Nothing to press, nothing lost."
|
||||
fi
|
||||
info "Backups: $backup"
|
||||
}
|
||||
|
||||
# The 'omarchy' metapackage hard-depends on limine, limine-mkinitcpio-hook and
|
||||
# limine-snapper-sync, so they can only be forced out with -Rdd - and the next
|
||||
# omarchy upgrade will resolve those dependencies and pull them straight back in.
|
||||
#
|
||||
# NoExtract makes that harmless: pacman may reinstall the packages, but it will
|
||||
# never write the files that actually do anything. Only the active parts are
|
||||
# listed - the pacman hooks that rebuild unified kernel images and redeploy
|
||||
# Limine onto the ESP, plus the /usr/local/bin/mkinitcpio wrapper that shadows
|
||||
# the real one. Delete these lines from /etc/pacman.conf to undo it.
|
||||
guard_limine_files() {
|
||||
local marker="# Limine neutralised by blob_boot.sh"
|
||||
if grep -q "$marker" /etc/pacman.conf; then
|
||||
info "pacman.conf guard already present"
|
||||
return 0
|
||||
fi
|
||||
|
||||
sudo cp /etc/pacman.conf /etc/pacman.conf.bak
|
||||
|
||||
# These are [options] directives, so they have to go inside that section.
|
||||
# Appending to the end of the file would land them in the last repo block,
|
||||
# where pacman would ignore them.
|
||||
sudo awk -v marker="$marker" '
|
||||
/^\[options\]/ && !done {
|
||||
print
|
||||
print marker
|
||||
print "NoExtract = etc/pacman.d/hooks/90-mkinitcpio-install.hook"
|
||||
print "NoExtract = usr/local/bin/mkinitcpio"
|
||||
print "NoExtract = usr/share/libalpm/hooks/60-limine-mkinitcpio-remove-pre.hook"
|
||||
print "NoExtract = usr/share/libalpm/hooks/80-limine-efi-deploy.hook"
|
||||
print "NoExtract = usr/share/libalpm/hooks/90-limine-mkinitcpio-remove-post.hook"
|
||||
done = 1
|
||||
next
|
||||
}
|
||||
{ print }
|
||||
' /etc/pacman.conf.bak | sudo tee /etc/pacman.conf >/dev/null
|
||||
|
||||
grep -q "$marker" /etc/pacman.conf \
|
||||
|| die "Failed to write the NoExtract guard. Your original is at /etc/pacman.conf.bak - restore it before doing anything else."
|
||||
info "added NoExtract guard to /etc/pacman.conf (backup: /etc/pacman.conf.bak)"
|
||||
}
|
||||
|
||||
# pacman refuses a plain -R because omarchy depends on these. Force it, but only
|
||||
# once the guard is in place, so a later reinstall cannot resurrect the hooks.
|
||||
drop_limine_pkgs() {
|
||||
local present=() pkg
|
||||
for pkg in "$@"; do
|
||||
pacman -Qq "$pkg" &>/dev/null && present+=("$pkg")
|
||||
done
|
||||
if (( ${#present[@]} == 0 )); then
|
||||
info "nothing to remove"
|
||||
return 0
|
||||
fi
|
||||
|
||||
guard_limine_files
|
||||
|
||||
if sudo pacman -Rn --noconfirm "${present[@]}" 2>/dev/null; then
|
||||
info "removed: ${present[*]}"
|
||||
else
|
||||
warn "the 'omarchy' metapackage depends on these; forcing removal with -Rdd"
|
||||
sudo pacman -Rddn --noconfirm "${present[@]}"
|
||||
info "removed: ${present[*]}"
|
||||
warn "'omarchy' will now report unsatisfied dependencies. That is expected and"
|
||||
warn "harmless - nothing checks them outside of a pacman transaction. If a"
|
||||
warn "future omarchy upgrade reinstalls them, the guard keeps them inert."
|
||||
fi
|
||||
}
|
||||
|
||||
# The ESP is 95% full. Reclaim only files that are provably unreachable, and
|
||||
# leave Limine's own UKI alone so the machine keeps a working fallback.
|
||||
reclaim_esp_space() {
|
||||
local machine_id=$1 backup=$2
|
||||
log "Reclaiming space on the ESP ($(df -h --output=avail /boot | tail -1 | tr -d ' ') free)"
|
||||
|
||||
# Limine's entry tool writes one kernel directory per machine-id. A
|
||||
# directory keyed by a different machine-id is left over from a previous
|
||||
# install and nothing in NVRAM or limine.conf can reach it.
|
||||
local dir name
|
||||
shopt -s nullglob
|
||||
for dir in /boot/[0-9a-f]*; do
|
||||
name=${dir##*/}
|
||||
[[ ${#name} -eq 32 && $name =~ ^[0-9a-f]+$ ]] || continue
|
||||
[[ $name == "$machine_id" ]] && { info "keeping $name (this machine)"; continue; }
|
||||
info "removing stale kernel dir $name ($(sudo du -sh "$dir" | cut -f1))"
|
||||
sudo rm -rf "$dir"
|
||||
done
|
||||
shopt -u nullglob
|
||||
|
||||
# arch-linux.efi is what the mkinitcpio preset wrote; limine.conf boots
|
||||
# omarchy_linux.efi and never reads it. Only drop it while the UKI that
|
||||
# Limine actually boots is present, so a fallback always survives.
|
||||
if [ -f /boot/EFI/Linux/arch-linux.efi ]; then
|
||||
if [ -f /boot/EFI/Linux/omarchy_linux.efi ] && ! grep -q "arch-linux.efi" /boot/limine.conf 2>/dev/null; then
|
||||
info "removing unreferenced UKI arch-linux.efi ($(sudo du -sh /boot/EFI/Linux/arch-linux.efi | cut -f1))"
|
||||
sudo rm -f /boot/EFI/Linux/arch-linux.efi
|
||||
else
|
||||
warn "arch-linux.efi is still referenced; keeping it"
|
||||
fi
|
||||
fi
|
||||
|
||||
local avail_kb
|
||||
avail_kb=$(df --output=avail -k /boot | tail -1 | tr -d ' ')
|
||||
info "ESP now has $((avail_kb / 1024)) MB free"
|
||||
(( avail_kb > 61440 )) || die "Only $((avail_kb / 1024)) MB free; an initramfs needs ~60 MB. Nothing further has been changed."
|
||||
}
|
||||
|
||||
restore_standard_initramfs() {
|
||||
log "Restoring a standard kernel + initramfs layout"
|
||||
|
||||
# btrfs-overlayfs ships with limine-mkinitcpio-hook and is about to vanish.
|
||||
# omarchy_hooks.conf assigns HOOKS wholesale, so filter the hook out from a
|
||||
# drop-in that sorts after it instead of editing an Omarchy-managed file.
|
||||
sudo tee /etc/mkinitcpio.conf.d/zz-no-limine.conf >/dev/null <<'EOF'
|
||||
# Written when this machine was migrated from Limine back to GRUB.
|
||||
# btrfs-overlayfs comes from limine-mkinitcpio-hook, which is no longer
|
||||
# installed, and this root filesystem is ext4 - so drop the hook rather than
|
||||
# let mkinitcpio fail on a missing one.
|
||||
_hooks=()
|
||||
for _hook in "${HOOKS[@]}"; do
|
||||
[[ $_hook == "btrfs-overlayfs" ]] || _hooks+=("$_hook")
|
||||
done
|
||||
HOOKS=("${_hooks[@]}")
|
||||
unset _hooks _hook
|
||||
EOF
|
||||
info "wrote /etc/mkinitcpio.conf.d/zz-no-limine.conf"
|
||||
|
||||
# Only the 'default' preset: Limine's 51 MB UKI is still on the ESP as a
|
||||
# fallback and a fallback initramfs will not fit beside it. The cleanup
|
||||
# step turns the fallback on once that space comes back.
|
||||
sudo tee /etc/mkinitcpio.d/linux.preset >/dev/null <<'EOF'
|
||||
# mkinitcpio preset file for the 'linux' package
|
||||
|
||||
#ALL_config="/etc/mkinitcpio.conf"
|
||||
ALL_kver="/boot/vmlinuz-linux"
|
||||
|
||||
PRESETS=('default')
|
||||
|
||||
#default_config="/etc/mkinitcpio.conf"
|
||||
default_image="/boot/initramfs-linux.img"
|
||||
#default_uki="/boot/EFI/Linux/arch-linux.efi"
|
||||
default_options=""
|
||||
|
||||
#fallback_config="/etc/mkinitcpio.conf"
|
||||
fallback_image="/boot/initramfs-linux-fallback.img"
|
||||
#fallback_uki="/boot/EFI/Linux/arch-linux-fallback.efi"
|
||||
fallback_options="-S autodetect"
|
||||
EOF
|
||||
info "wrote /etc/mkinitcpio.d/linux.preset (image, not UKI)"
|
||||
|
||||
# The 'limine' package itself stays for now - EFI/limine plus limine.conf
|
||||
# remain a working fallback. Only the pieces that hijack mkinitcpio go.
|
||||
log "Removing Limine's mkinitcpio integration"
|
||||
drop_limine_pkgs limine-mkinitcpio-hook limine-snapper-sync
|
||||
[ -e /usr/local/bin/mkinitcpio ] && warn "/usr/local/bin/mkinitcpio still shadows /usr/bin/mkinitcpio"
|
||||
|
||||
log "Building the initramfs"
|
||||
rebuild_initramfs
|
||||
[ -f /boot/initramfs-linux.img ] || die "mkinitcpio produced no /boot/initramfs-linux.img. Limine is still bootable - do NOT reboot into GRUB."
|
||||
[ -f /boot/vmlinuz-linux ] || die "/boot/vmlinuz-linux is missing."
|
||||
info "initramfs: $(du -h /boot/initramfs-linux.img | cut -f1)"
|
||||
}
|
||||
|
||||
# The previous GRUB setup booted a UKI: 10_linux had been made non-executable
|
||||
# and a custom 15_uki emitted a bare 'uki' command in its place. That is why the
|
||||
# old menu listed Windows and Ubuntu but no Arch entry at all. Back on a normal
|
||||
# kernel + initramfs, that has to be undone or the menu cannot boot this system.
|
||||
configure_grub_scripts() {
|
||||
log "Fixing the GRUB menu generators"
|
||||
|
||||
if [ -f /etc/grub.d/10_linux ] && [ ! -x /etc/grub.d/10_linux ]; then
|
||||
sudo chmod +x /etc/grub.d/10_linux
|
||||
info "enabled 10_linux (generates the Arch entries)"
|
||||
fi
|
||||
|
||||
if [ -x /etc/grub.d/15_uki ]; then
|
||||
sudo chmod -x /etc/grub.d/15_uki
|
||||
info "disabled 15_uki (no unified kernel image any more)"
|
||||
fi
|
||||
|
||||
[ -x /etc/grub.d/30_os-prober ] || { sudo chmod +x /etc/grub.d/30_os-prober; info "enabled 30_os-prober"; }
|
||||
}
|
||||
|
||||
install_grub() {
|
||||
local esp_uuid=$1
|
||||
log "Configuring GRUB"
|
||||
|
||||
set_grub_key() {
|
||||
local key=$1 val=$2
|
||||
if grep -q "^${key}=" /etc/default/grub; then
|
||||
sudo sed -i "s|^${key}=.*|${key}=${val}|" /etc/default/grub
|
||||
elif grep -qE "^#\s*${key}=" /etc/default/grub; then
|
||||
sudo sed -i "0,/^#\s*${key}=.*/s||${key}=${val}|" /etc/default/grub
|
||||
else
|
||||
echo "${key}=${val}" | sudo tee -a /etc/default/grub >/dev/null
|
||||
fi
|
||||
info "${key}=${val}"
|
||||
}
|
||||
|
||||
# Carry over the exact kernel command line Limine was booting, so Plymouth
|
||||
# and the quiet splash behave the way they do today.
|
||||
set_grub_key GRUB_CMDLINE_LINUX_DEFAULT '"rtc_cmos.use_acpi_alarm=1 initramfs_async=0 quiet splash loglevel=0 systemd.show_status=false rd.udev.log_level=0 vt.global_cursor_default=0"'
|
||||
set_grub_key GRUB_DISABLE_OS_PROBER 'false'
|
||||
set_grub_key GRUB_TIMEOUT '5'
|
||||
set_grub_key GRUB_TIMEOUT_STYLE 'menu'
|
||||
set_grub_key GRUB_GFXPAYLOAD_LINUX 'keep'
|
||||
|
||||
log "Installing GRUB to the ESP"
|
||||
sudo grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=GRUB --recheck
|
||||
[ -f /boot/EFI/GRUB/grubx64.efi ] || die "grub-install produced no /boot/EFI/GRUB/grubx64.efi."
|
||||
|
||||
generate_menu "$esp_uuid"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# OS detection
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
generate_menu() {
|
||||
local esp_uuid=${1:-$(findmnt -no UUID /boot)}
|
||||
|
||||
log "Generating the GRUB menu (os-prober scans for other systems)"
|
||||
sudo grub-mkconfig -o /boot/grub/grub.cfg
|
||||
|
||||
# os-prober can miss Windows when its EFI System Partition is the very one
|
||||
# mounted at /boot, as it is here. Chainload it explicitly rather than ship
|
||||
# a menu with no Windows in it.
|
||||
if grep -qi "bootmgfw.efi" /boot/grub/grub.cfg; then
|
||||
info "os-prober found Windows"
|
||||
elif [ -f /boot/EFI/Microsoft/Boot/bootmgfw.efi ]; then
|
||||
warn "os-prober missed Windows; adding an explicit chainload entry"
|
||||
sudo tee /etc/grub.d/40_custom >/dev/null <<EOF
|
||||
#!/bin/sh
|
||||
exec tail -n +3 \$0
|
||||
# Entries below are added to the end of the GRUB menu.
|
||||
|
||||
# os-prober does not reliably detect Windows when its EFI System Partition is
|
||||
# the same one mounted at /boot, so chainload the Windows boot manager directly.
|
||||
menuentry "Windows Boot Manager" --class windows --class os {
|
||||
insmod part_gpt
|
||||
insmod fat
|
||||
insmod chain
|
||||
search --no-floppy --fs-uuid --set=root ${esp_uuid}
|
||||
chainloader /EFI/Microsoft/Boot/bootmgfw.efi
|
||||
}
|
||||
EOF
|
||||
sudo chmod +x /etc/grub.d/40_custom
|
||||
sudo grub-mkconfig -o /boot/grub/grub.cfg
|
||||
fi
|
||||
|
||||
verify_menu
|
||||
}
|
||||
|
||||
# Refuse to leave the machine with a menu that cannot boot it.
|
||||
verify_menu() {
|
||||
local entries
|
||||
entries=$(grep -cE "^\s*menuentry " /boot/grub/grub.cfg || true)
|
||||
|
||||
grep -qE "^\s*menuentry .*(Arch|Linux)" /boot/grub/grub.cfg \
|
||||
|| die "grub.cfg contains no Arch entry. Check that /etc/grub.d/10_linux is executable and that /boot/vmlinuz-linux and /boot/initramfs-linux.img both exist. Do NOT reboot into GRUB until this is fixed."
|
||||
|
||||
grep -q "initramfs-linux.img" /boot/grub/grub.cfg \
|
||||
|| warn "no initramfs referenced in grub.cfg - check the Arch entry by hand"
|
||||
|
||||
info "$entries menu entries generated"
|
||||
}
|
||||
|
||||
list_menu_entries() {
|
||||
grep -E "^\s*(menuentry|submenu) '" /boot/grub/grub.cfg \
|
||||
| sed -E "s/^[[:space:]]*(menuentry|submenu) '([^']*)'.*/ - \2/"
|
||||
}
|
||||
|
||||
detect_os() {
|
||||
using_grub || die "GRUB is not installed yet. Run: $0 grub"
|
||||
require_sudo
|
||||
generate_menu
|
||||
echo
|
||||
info "GRUB menu entries:"
|
||||
list_menu_entries
|
||||
ok "OS detection complete."
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Boot-time failures
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# Two units fail on every boot on this machine, both left over from a btrfs
|
||||
# layout that no longer exists - the root filesystem is ext4.
|
||||
fix_failing_boot_units() {
|
||||
log "Clearing boot-time unit failures"
|
||||
|
||||
# fstab still lists a btrfs hibernation swapfile that was never created.
|
||||
# zram provides this machine's swap.
|
||||
if grep -q "^/swap/swapfile" /etc/fstab && [ ! -f /swap/swapfile ]; then
|
||||
sudo cp /etc/fstab /etc/fstab.bak
|
||||
sudo sed -i '/^# Btrfs swapfile for system hibernation$/d; /^\/swap\/swapfile/d' /etc/fstab
|
||||
sudo systemctl daemon-reload
|
||||
info "removed the missing /swap/swapfile entry from fstab (backup: /etc/fstab.bak)"
|
||||
fi
|
||||
|
||||
# snapper only manages btrfs subvolumes; on ext4 its timers fail nightly.
|
||||
if systemctl list-unit-files snapper-cleanup.timer &>/dev/null \
|
||||
&& [ "$(findmnt -no FSTYPE /)" != "btrfs" ]; then
|
||||
sudo systemctl disable --now snapper-cleanup.timer snapper-timeline.timer &>/dev/null || true
|
||||
sudo systemctl reset-failed snapper-cleanup.service &>/dev/null || true
|
||||
info "disabled snapper timers (root is $(findmnt -no FSTYPE /), not btrfs)"
|
||||
fi
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Boot order
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# efibootmgr prints "Boot0000* GRUB<TAB>HD(1,GPT,...)" on this machine - the
|
||||
# device path is there even without -v - so match the label field exactly
|
||||
# rather than anchoring on end of line.
|
||||
efi_entry_num() {
|
||||
sudo efibootmgr | awk -F'\t' -v want="$1" '
|
||||
$1 ~ /^Boot[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]/ {
|
||||
num = substr($1, 5, 4)
|
||||
label = $1
|
||||
sub(/^Boot[0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f][0-9A-Fa-f]\*? +/, "", label)
|
||||
if (label == want) { print num; exit }
|
||||
}'
|
||||
}
|
||||
|
||||
# Boot GRUB exactly once, leaving Limine as the standing default. A failed
|
||||
# GRUB boot then needs no user intervention at all to recover.
|
||||
set_boot_next() {
|
||||
log "Arming GRUB for a one-shot test boot"
|
||||
local grub_num limine_num
|
||||
grub_num=$(efi_entry_num GRUB)
|
||||
[ -n "$grub_num" ] || die "No GRUB entry in NVRAM after grub-install. BootOrder is untouched, so this machine still boots Limine."
|
||||
|
||||
# grub-install prepends its own entry to BootOrder, which would make GRUB the
|
||||
# standing default and defeat the whole point of a one-shot test. Put Limine
|
||||
# back in front so a failed GRUB boot recovers on a plain power cycle.
|
||||
limine_num=$(efi_entry_num Limine)
|
||||
if [ -n "$limine_num" ]; then
|
||||
local current_order new_order n
|
||||
current_order=$(sudo efibootmgr | sed -n 's/^BootOrder: //p')
|
||||
new_order=$limine_num
|
||||
for n in ${current_order//,/ }; do
|
||||
[ "$n" = "$limine_num" ] || new_order+=",$n"
|
||||
done
|
||||
sudo efibootmgr -o "$new_order" >/dev/null
|
||||
info "BootOrder: $new_order (Limine first - the standing default)"
|
||||
else
|
||||
warn "no Limine entry in NVRAM; GRUB will be the standing default with no automatic fallback"
|
||||
fi
|
||||
|
||||
# Set BootNext last: it must survive the BootOrder rewrite above.
|
||||
sudo efibootmgr -n "$grub_num" >/dev/null
|
||||
info "BootNext=$grub_num (GRUB) - next restart only"
|
||||
}
|
||||
|
||||
set_boot_order() {
|
||||
log "Setting the firmware boot order"
|
||||
|
||||
local grub_num limine_num current_order new_order n
|
||||
grub_num=$(efi_entry_num GRUB)
|
||||
limine_num=$(efi_entry_num Limine)
|
||||
|
||||
[ -n "$grub_num" ] || die "No GRUB entry in NVRAM after grub-install. Limine is still first in the boot order, so the machine remains bootable."
|
||||
|
||||
current_order=$(sudo efibootmgr | sed -n 's/^BootOrder: //p')
|
||||
new_order=$grub_num
|
||||
[ -n "$limine_num" ] && new_order+=",$limine_num"
|
||||
for n in ${current_order//,/ }; do
|
||||
[ "$n" = "$grub_num" ] && continue
|
||||
[ "$n" = "${limine_num:-}" ] && continue
|
||||
new_order+=",$n"
|
||||
done
|
||||
|
||||
sudo efibootmgr -o "$new_order" >/dev/null
|
||||
info "BootOrder: $new_order (GRUB=$grub_num, Limine=${limine_num:-none})"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Retire Limine
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# Delete every trace of Limine and hand its EFI fallback path to GRUB.
|
||||
purge_limine() {
|
||||
log "Removing Limine"
|
||||
|
||||
drop_limine_pkgs limine limine-mkinitcpio-hook limine-snapper-sync
|
||||
|
||||
# limine-snapper-sync leaves units behind that would fail on every boot once
|
||||
# there is no limine.conf for them to write into.
|
||||
sudo systemctl disable --now limine-snapper-sync.service limine-snapper-sync.timer &>/dev/null || true
|
||||
|
||||
# /boot/EFI/Linux holds only the unified kernel images Limine booted; with
|
||||
# GRUB on a normal kernel + initramfs nothing reads them any more. This is
|
||||
# where the 51 MB comes back.
|
||||
sudo rm -rf /boot/EFI/limine /boot/EFI/Linux
|
||||
sudo rm -f /boot/limine.conf /boot/limine.conf.bak /boot/limine.conf.old
|
||||
sudo rm -rf /etc/limine-entry-tool.d /etc/limine-entry-tool.conf /var/lib/limine
|
||||
info "removed Limine's files from the ESP"
|
||||
|
||||
local limine_num
|
||||
limine_num=$(efi_entry_num Limine)
|
||||
[ -n "$limine_num" ] && { sudo efibootmgr -b "$limine_num" -B >/dev/null; info "removed the Limine NVRAM entry"; }
|
||||
|
||||
# EFI/BOOT/BOOTX64.EFI is still Limine's copy. Overwrite it with GRUB so the
|
||||
# firmware's default fallback path keeps working if the NVRAM entry is ever
|
||||
# lost - otherwise deleting Limine leaves a dead pointer there.
|
||||
log "Claiming the removable EFI fallback path for GRUB"
|
||||
sudo grub-install --target=x86_64-efi --efi-directory=/boot --bootloader-id=GRUB --removable --recheck
|
||||
}
|
||||
|
||||
# Only worth attempting once Limine's UKI has freed up room on the ESP.
|
||||
enable_fallback_initramfs() {
|
||||
log "Enabling the fallback initramfs"
|
||||
sudo sed -i "s|^PRESETS=.*|PRESETS=('default' 'fallback')|" /etc/mkinitcpio.d/linux.preset
|
||||
if ! rebuild_initramfs; then
|
||||
warn "the fallback initramfs did not build - reverting to the default preset only"
|
||||
sudo sed -i "s|^PRESETS=.*|PRESETS=('default')|" /etc/mkinitcpio.d/linux.preset
|
||||
sudo rm -f /boot/initramfs-linux-fallback.img
|
||||
rebuild_initramfs
|
||||
fi
|
||||
}
|
||||
|
||||
cleanup_limine() {
|
||||
using_grub || die "GRUB is not installed. Run: $0 grub"
|
||||
require_sudo
|
||||
|
||||
# Only safe once the machine has actually come up through GRUB.
|
||||
local current
|
||||
current=$(sudo efibootmgr | sed -n 's/^BootCurrent: //p')
|
||||
[ -n "$current" ] || die "Cannot determine the current boot entry."
|
||||
sudo efibootmgr | grep -qE "^Boot${current}\*?[[:space:]]+GRUB" \
|
||||
|| die "This session did not boot through GRUB (BootCurrent=$current). Reboot first - GRUB is armed for the next restart - then run this again."
|
||||
|
||||
purge_limine
|
||||
set_boot_order
|
||||
enable_fallback_initramfs
|
||||
generate_menu
|
||||
|
||||
echo
|
||||
df -h /boot | tail -1 | sed 's/^/ /'
|
||||
echo
|
||||
info "GRUB menu entries:"
|
||||
list_menu_entries
|
||||
ok "Limine is gone. GRUB is now the only bootloader."
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Status
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
show_status() {
|
||||
log "Bootloader"
|
||||
if using_grub; then
|
||||
info "GRUB installed at /boot/EFI/GRUB/grubx64.efi"
|
||||
else
|
||||
warn "GRUB is not installed"
|
||||
fi
|
||||
pacman -Qq limine &>/dev/null && warn "limine is still installed"
|
||||
[ -e /usr/local/bin/mkinitcpio ] && warn "/usr/local/bin/mkinitcpio shadows /usr/bin/mkinitcpio"
|
||||
|
||||
log "Kernel images"
|
||||
ls -lh /boot/vmlinuz-linux /boot/initramfs-linux*.img /boot/EFI/Linux/*.efi 2>/dev/null \
|
||||
| awk '{print " " $5 "\t" $9}'
|
||||
|
||||
log "ESP usage"
|
||||
df -h /boot | tail -1 | sed 's/^/ /'
|
||||
|
||||
if [ -f /boot/grub/grub.cfg ]; then
|
||||
log "GRUB menu entries"
|
||||
list_menu_entries
|
||||
fi
|
||||
|
||||
log "Firmware boot order"
|
||||
sudo efibootmgr 2>/dev/null | grep -E "^(BootCurrent|BootOrder|Boot[0-9A-F]{4})" \
|
||||
| grep -viE "USB|Setup|Boot Menu|Diagnostics|NVMe:" | sed 's/^/ /'
|
||||
|
||||
log "Failed units"
|
||||
systemctl --failed --no-pager --no-legend | sed 's/^/ /' || info "none"
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
case "${1:-}" in
|
||||
grub|migrate) migrate_to_grub "${2:-}" ;;
|
||||
detect|osprobe) detect_os ;;
|
||||
cleanup) cleanup_limine ;;
|
||||
status) show_status ;;
|
||||
-h|--help|help)
|
||||
sed -n '3,11p' "$0" | sed 's/^# \?//'
|
||||
;;
|
||||
*) apply_splash "${1:-}" ;;
|
||||
esac
|
||||
|
||||
Executable
+72
@@ -0,0 +1,72 @@
|
||||
#!/bin/bash
|
||||
|
||||
STATE_FILE="$HOME/.local/state/omarchy/indicators/tablet-follow-focus"
|
||||
SOCKET="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock"
|
||||
TABLET_DEVICE="${OMARCHY_TABLET_DEVICE:-hanvon-ugee-6-inch-pentablet-pen}"
|
||||
PIDFILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-tablet-follow-focus.pid"
|
||||
|
||||
if [ "$(ps -o pgid= -p $$ | tr -d ' ')" != "$$" ]; then
|
||||
exec setsid "$0" "$@"
|
||||
fi
|
||||
|
||||
echo $$ >"$PIDFILE"
|
||||
trap 'rm -f "$PIDFILE"' EXIT
|
||||
|
||||
selected_mode() {
|
||||
local mode
|
||||
[ -f "$STATE_FILE" ] || { echo all; return; }
|
||||
mode=$(cat "$STATE_FILE" 2>/dev/null)
|
||||
echo "${mode:-follow}"
|
||||
}
|
||||
|
||||
focused_monitor() {
|
||||
hyprctl monitors -j | jq -r '.[] | select(.focused==true) | .name'
|
||||
}
|
||||
|
||||
monitor_exists() {
|
||||
hyprctl monitors -j | jq -e --arg name "$1" 'any(.[]; .name == $name)' >/dev/null
|
||||
}
|
||||
|
||||
map_to_output() {
|
||||
hyprctl eval "hl.device({ name = \"$TABLET_DEVICE\", output = \"$1\" })" >/dev/null
|
||||
}
|
||||
|
||||
apply_selected_mode() {
|
||||
local mode target
|
||||
mode=$(selected_mode)
|
||||
|
||||
case "$mode" in
|
||||
all)
|
||||
map_to_output ""
|
||||
;;
|
||||
follow)
|
||||
target=$(focused_monitor)
|
||||
[ -n "$target" ] && map_to_output "$target"
|
||||
;;
|
||||
*)
|
||||
if monitor_exists "$mode"; then
|
||||
map_to_output "$mode"
|
||||
else
|
||||
target=$(focused_monitor)
|
||||
[ -n "$target" ] && map_to_output "$target"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
while true; do
|
||||
apply_selected_mode
|
||||
|
||||
socat -U - UNIX-CONNECT:"$SOCKET" | while read -r line; do
|
||||
case "$line" in
|
||||
focusedmon\>\>*)
|
||||
[ "$(selected_mode)" = "follow" ] && apply_selected_mode
|
||||
;;
|
||||
monitoradded\>\>*|monitorremoved\>\>*)
|
||||
apply_selected_mode
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
sleep 1
|
||||
done
|
||||
Executable
+147
@@ -0,0 +1,147 @@
|
||||
#!/bin/bash
|
||||
|
||||
STATE_DIR="$HOME/.local/state/omarchy/indicators"
|
||||
STATE_FILE="$STATE_DIR/tablet-follow-focus"
|
||||
DAEMON="omarchy-tablet-follow-focus"
|
||||
PIDFILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-tablet-follow-focus.pid"
|
||||
TABLET_DEVICE="${OMARCHY_TABLET_DEVICE:-hanvon-ugee-6-inch-pentablet-pen}"
|
||||
|
||||
selected_mode() {
|
||||
local mode
|
||||
[ -f "$STATE_FILE" ] || { echo all; return; }
|
||||
mode=$(cat "$STATE_FILE" 2>/dev/null)
|
||||
echo "${mode:-follow}"
|
||||
}
|
||||
|
||||
write_mode() {
|
||||
mkdir -p "$STATE_DIR"
|
||||
printf '%s\n' "$1" >"$STATE_FILE"
|
||||
}
|
||||
|
||||
daemon_pid() {
|
||||
[ -f "$PIDFILE" ] || return 1
|
||||
local pid
|
||||
pid=$(<"$PIDFILE")
|
||||
[ -n "$pid" ] && kill -0 "$pid" 2>/dev/null && echo "$pid"
|
||||
}
|
||||
|
||||
daemon_running() {
|
||||
daemon_pid >/dev/null
|
||||
}
|
||||
|
||||
start_daemon() {
|
||||
daemon_running && return
|
||||
setsid uwsm-app -- "$DAEMON" >/dev/null 2>&1 &
|
||||
disown
|
||||
}
|
||||
|
||||
stop_daemon() {
|
||||
local pid
|
||||
pid=$(daemon_pid) && kill -- "-$pid" 2>/dev/null
|
||||
rm -f "$PIDFILE"
|
||||
return 0
|
||||
}
|
||||
|
||||
map_to_output() {
|
||||
hyprctl eval "hl.device({ name = \"$TABLET_DEVICE\", output = \"$1\" })" >/dev/null
|
||||
}
|
||||
|
||||
focused_monitor() {
|
||||
hyprctl monitors -j | jq -r '.[] | select(.focused==true) | .name'
|
||||
}
|
||||
|
||||
monitor_exists() {
|
||||
hyprctl monitors -j | jq -e --arg name "$1" 'any(.[]; .name == $name)' >/dev/null
|
||||
}
|
||||
|
||||
active_output() {
|
||||
local mode
|
||||
mode=$(selected_mode)
|
||||
case "$mode" in
|
||||
all) echo "" ;;
|
||||
follow) focused_monitor ;;
|
||||
*) echo "$mode" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
describe_mode() {
|
||||
case "$1" in
|
||||
all) echo "Tablet spans all monitors" ;;
|
||||
follow) echo "Tablet follows focused monitor" ;;
|
||||
*) echo "Tablet locked to $1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
select_mode() {
|
||||
local mode="$1"
|
||||
|
||||
case "$mode" in
|
||||
all)
|
||||
write_mode all
|
||||
stop_daemon
|
||||
map_to_output ""
|
||||
;;
|
||||
follow)
|
||||
write_mode follow
|
||||
start_daemon
|
||||
;;
|
||||
*)
|
||||
if ! monitor_exists "$mode"; then
|
||||
echo "Unknown monitor: $mode" >&2
|
||||
exit 1
|
||||
fi
|
||||
write_mode "$mode"
|
||||
start_daemon
|
||||
map_to_output "$mode"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
print_status() {
|
||||
local mode enabled
|
||||
mode=$(selected_mode)
|
||||
if [ "$mode" = "all" ]; then enabled=false; else enabled=true; fi
|
||||
|
||||
jq -nc \
|
||||
--argjson enabled "$enabled" \
|
||||
--arg mode "$mode" \
|
||||
--arg output "$(active_output)" \
|
||||
--arg tooltip "$(describe_mode "$mode")" \
|
||||
'{enabled: $enabled, mode: $mode, output: $output, class: (if $enabled then "enabled" else "disabled" end), tooltip: $tooltip}'
|
||||
}
|
||||
|
||||
print_monitors() {
|
||||
local mode
|
||||
mode=$(selected_mode)
|
||||
hyprctl monitors -j | jq -c --arg mode "$mode" \
|
||||
'[.[] | {name, description, model, focused, selected: (.name == $mode)}]'
|
||||
}
|
||||
|
||||
case "${1:-toggle}" in
|
||||
toggle)
|
||||
if [ "$(selected_mode)" = "all" ]; then select_mode follow; else select_mode all; fi
|
||||
;;
|
||||
on|enable)
|
||||
select_mode follow
|
||||
;;
|
||||
off|disable)
|
||||
select_mode all
|
||||
;;
|
||||
set)
|
||||
[ -n "$2" ] || { echo "Usage: omarchy-toggle-tablet-follow set <monitor|follow|all>" >&2; exit 1; }
|
||||
select_mode "$2"
|
||||
;;
|
||||
status|--status)
|
||||
print_status
|
||||
;;
|
||||
monitors)
|
||||
print_monitors
|
||||
;;
|
||||
autostart)
|
||||
[ "$(selected_mode)" = "all" ] || start_daemon
|
||||
;;
|
||||
*)
|
||||
echo "Usage: omarchy-toggle-tablet-follow [toggle|on|off|set <monitor|follow|all>|status|monitors|autostart]" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user