Compare commits
13
Commits
ce95c9be9c
...
b144445f0d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b144445f0d | ||
|
|
1fbd72a2ba | ||
|
|
78a9da3deb | ||
|
|
076561c428 | ||
|
|
000ff69560 | ||
|
|
96759d68fd | ||
|
|
55a0b5a06c | ||
|
|
a51586fce4 | ||
|
|
d0d23fb806 | ||
|
|
a4953b8c43 | ||
|
|
700b9b4a5d | ||
|
|
b55f925281 | ||
|
|
1c25b44b8e |
@@ -23,7 +23,7 @@ My current setup is built around these core components:
|
|||||||
### Directory Structure
|
### 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.
|
- **`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.
|
- **`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).
|
- **`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).
|
- **`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).
|
||||||
|
|||||||
@@ -66,6 +66,14 @@ function localDateKey(date: Date) {
|
|||||||
return `${date.getFullYear()}-${month}-${day}`
|
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) {
|
function formatResetsIn(resetsAt: string) {
|
||||||
const resetTime = Date.parse(resetsAt)
|
const resetTime = Date.parse(resetsAt)
|
||||||
if (!resetTime) return ""
|
if (!resetTime) return ""
|
||||||
@@ -76,10 +84,11 @@ function formatResetsIn(resetsAt: string) {
|
|||||||
const days = Math.floor(minutesLeft / 1440)
|
const days = Math.floor(minutesLeft / 1440)
|
||||||
const hours = Math.floor((minutesLeft % 1440) / 60)
|
const hours = Math.floor((minutesLeft % 1440) / 60)
|
||||||
const minutes = minutesLeft % 60
|
const minutes = minutesLeft % 60
|
||||||
|
const clockTime = formatClockTime(new Date(resetTime))
|
||||||
|
|
||||||
if (days > 0) return `Resets in ${days}d ${hours}h`
|
if (days > 0) return `Resets in ${days}d ${hours}h (${clockTime})`
|
||||||
if (hours > 0) return `Resets in ${hours}h ${minutes}m`
|
if (hours > 0) return `Resets in ${hours}h ${minutes}m (${clockTime})`
|
||||||
return `Resets in ${minutes}m`
|
return `Resets in ${minutes}m (${clockTime})`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatModelName(model: string) {
|
function formatModelName(model: string) {
|
||||||
|
|||||||
@@ -450,7 +450,7 @@ function WeatherCard() {
|
|||||||
/>
|
/>
|
||||||
<box vertical spacing={8} visible={weather.as((w) => w.ok)}>
|
<box vertical spacing={8} visible={weather.as((w) => w.ok)}>
|
||||||
<StatRow icon={""} label="Temperature" value={weather.as((w) => w.temp)} />
|
<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>
|
||||||
</box>
|
</box>
|
||||||
)
|
)
|
||||||
|
|||||||
+26
-26
@@ -1,26 +1,26 @@
|
|||||||
█████████████████████████████████████████████████████
|
@@@@@@@@@@@@
|
||||||
█████████████████████████████████████████████████████
|
@@@@ @@@@
|
||||||
████ ████ ████
|
@@@ @@@
|
||||||
████ ████ ████
|
@@@ @@@
|
||||||
████ █████████████████████ ████████ ████
|
@@@ @@
|
||||||
████ █████████████████████ ████████ ████
|
@@ @@ @@ @@
|
||||||
████ ████ ████ ████
|
@ @@ @@ @@ @@ @@
|
||||||
████ ████ ████ ████
|
@ @@ ##### @ @ @ @@
|
||||||
████ ████ ████ ████
|
@ @ ##### @ @ ##### @ @@
|
||||||
████ ████ ████ ████
|
@@ @ ### @ @ ###### @ @@
|
||||||
████ ████ ████ ████
|
@ @ @ @ ######@@ @@
|
||||||
████ ████ ████ ████
|
@@ @@@ @@@ @@ @@@ @
|
||||||
████████████ ████ ████
|
@@ @@
|
||||||
████████████ ████ ████
|
@@ @@
|
||||||
████ ████ ████ ████
|
@@ @
|
||||||
████ ████ ████ ████
|
@ @@
|
||||||
████ ████ ████ ████
|
@@ @@
|
||||||
████ ████ ████ ████
|
@@ @@
|
||||||
████ ████ ████ ████
|
@ @@
|
||||||
████ ████ ████ ████
|
@@ @@
|
||||||
████ ██████████████████████████████████████ ████
|
@@ @@
|
||||||
████ ██████████████████████████████████████ ████
|
@@@ @@
|
||||||
████ ████ ████
|
@@@ @@@
|
||||||
████ ████ ████
|
@@@ @@@
|
||||||
████████████████████████████ ████████████████████
|
@@@@ @@@@
|
||||||
████████████████████████████ ████████████████████
|
@@@@@@@@@@@@
|
||||||
|
|||||||
@@ -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/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.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.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/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/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
|
check_file "$SCRIPT_DIR/ags/app.ts" "$HOME_DIR/.config/ags/app.ts" "ags/app.ts" || check_status=1
|
||||||
|
|||||||
+119
-12
@@ -34,6 +34,22 @@ update indicator in its center. Add `{ "id": "omarchy.indicators" }` back to
|
|||||||
`center` to restore them (omit `items` for all six, which adds `NightLight`
|
`center` to restore them (omit `items` for all six, which adds `NightLight`
|
||||||
and `Reminder`).
|
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
|
## Custom modules
|
||||||
|
|
||||||
The bar accepts arbitrary ids with `type: "command"`, which is how the two
|
The bar accepts arbitrary ids with `type: "command"`, which is how the two
|
||||||
@@ -53,24 +69,115 @@ A command module with no `exec` key is a static icon; add `exec` and
|
|||||||
|
|
||||||
## Cloned plugins
|
## Cloned plugins
|
||||||
|
|
||||||
Both were made with `omarchy plugin clone`, which copies a built-in plugin,
|
`blob.workspaces` and `blob.menu` were made with `omarchy plugin clone`, which
|
||||||
disables the original, and points the bar at the copy - hence the `blob.` ids
|
copies a built-in plugin, disables the original, and points the bar at the copy
|
||||||
in `shell.json`.
|
- 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
|
`plugins/blob.workspaces/` clones `omarchy.workspaces`. The stock widget
|
||||||
hardcodes workspaces 1-5 as always visible and has no setting for it, so the
|
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`.
|
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
|
`plugins/blob.menu/` clones `omarchy.menu` to widen it and to let a menu row
|
||||||
`Menu.qml` is hardcoded at `Style.space(300)`; the clone raises it to 440, so
|
choose where it sits. `cardWidth` in `Menu.qml` is hardcoded at
|
||||||
the apps menu (Super + Space) and the root menu (Super + Alt + Space) are both
|
`Style.space(300)`; the clone raises it to 440, so the apps menu (Super + Space)
|
||||||
wider. The two oversized menus (screen recording, font picker) keep their own
|
and the root menu (Super + Alt + Space) are both wider. The two oversized menus
|
||||||
520 and are untouched. `omarchy-menu` still targets `omarchy.menu` on the CLI -
|
(screen recording, font picker) keep their own 520 and are untouched.
|
||||||
the manifest records `clonedFrom`, and the shell routes those calls here.
|
`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
|
`mergeMenuSources` in `MenuModel.js` reads the default menu first and the user
|
||||||
picking up: `omarchy plugin clone omarchy.workspaces`, then re-apply the
|
extension second, so a row that only exists in `extensions/omarchy-menu.jsonc`
|
||||||
one-line `workspaceIds()` change.
|
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
|
## Editing
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
// Blob entries added to the Quickshell Omarchy menu (Super + Space).
|
// Blob entries added to the Quickshell Omarchy menu (Super + Space).
|
||||||
// These replace the walker/elephant menus used before Omarchy 4.
|
// These replace the walker/elephant menus used before Omarchy 4.
|
||||||
|
|
||||||
"blob": { "icon": "", "label": "Blob" },
|
"blob": { "icon": "", "label": "Blob", "before": "learn" },
|
||||||
"blob.wallpaper": { "icon": "", "label": "Wallpaper", "action": "blob_wallpaper --menu", "description": "Pick a wallpaper from ~/wallpapers" },
|
"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.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.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.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ function normalizeItem(id, raw) {
|
|||||||
return {
|
return {
|
||||||
id: id,
|
id: id,
|
||||||
parent: parent,
|
parent: parent,
|
||||||
|
before: value.before || "",
|
||||||
kind: kind,
|
kind: kind,
|
||||||
icon: value.icon || "",
|
icon: value.icon || "",
|
||||||
iconFont: value.iconFont || "",
|
iconFont: value.iconFont || "",
|
||||||
@@ -62,6 +63,23 @@ function parseMenuJsonc(raw) {
|
|||||||
return out
|
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) {
|
function mergeMenuSources(defaultItems, userItems) {
|
||||||
var nextItems = ({})
|
var nextItems = ({})
|
||||||
var nextOrder = []
|
var nextOrder = []
|
||||||
@@ -82,8 +100,10 @@ function mergeMenuSources(defaultItems, userItems) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextOrder = applyBeforeHints(nextItems, nextOrder)
|
||||||
|
|
||||||
if (!nextItems.root) {
|
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")
|
nextOrder.unshift("root")
|
||||||
}
|
}
|
||||||
for (var k3 = 0; k3 < nextOrder.length; k3++) nextItems[nextOrder[k3]].order = k3
|
for (var k3 = 0; k3 < nextOrder.length; k3++) nextItems[nextOrder[k3]].order = k3
|
||||||
|
|||||||
+11
-4
@@ -2,9 +2,10 @@
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"idle": {
|
"idle": {
|
||||||
"screensaver": 300,
|
"screensaver": 300,
|
||||||
"lock": 301
|
"lock": 900
|
||||||
},
|
},
|
||||||
"bar": {
|
"bar": {
|
||||||
|
"id": "blob.bar",
|
||||||
"position": "top",
|
"position": "top",
|
||||||
"transparent": false,
|
"transparent": false,
|
||||||
"centerAnchor": "blob.clock",
|
"centerAnchor": "blob.clock",
|
||||||
@@ -73,11 +74,17 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins": [],
|
"plugins": [
|
||||||
|
{
|
||||||
|
"id": "blob.lock"
|
||||||
|
}
|
||||||
|
],
|
||||||
"disabledPlugins": [
|
"disabledPlugins": [
|
||||||
"omarchy.menu"
|
"omarchy.menu",
|
||||||
|
"omarchy.lock"
|
||||||
],
|
],
|
||||||
"cloneSourceRestores": [
|
"cloneSourceRestores": [
|
||||||
"blob.menu"
|
"blob.menu",
|
||||||
|
"blob.lock"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+632
-20
@@ -1,28 +1,640 @@
|
|||||||
#!/bin/bash
|
#!/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
|
set -euo pipefail
|
||||||
if [ -z "$1" ]; then
|
|
||||||
IMAGE_PATH="$HOME/Documents/dotfiles/branding/boot_flash.png"
|
|
||||||
else
|
|
||||||
IMAGE_PATH=$(realpath "$1")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ ! -f "$IMAGE_PATH" ]; then
|
DEFAULT_IMAGE="$HOME/Documents/dotfiles/branding/boot_flash.png"
|
||||||
echo "Error: File '$IMAGE_PATH' does not exist."
|
PLYMOUTH_LOGO="/usr/share/plymouth/themes/omarchy/logo.png"
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "Applying boot splash image: $IMAGE_PATH"
|
log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
|
||||||
echo "This requires sudo privileges."
|
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; }
|
||||||
|
|
||||||
# Copy the image to the Plymouth theme directory
|
require_sudo() {
|
||||||
sudo cp "$IMAGE_PATH" /usr/share/plymouth/themes/omarchy/logo.png
|
echo "This requires sudo privileges."
|
||||||
|
sudo -v || die "Could not obtain sudo."
|
||||||
|
}
|
||||||
|
|
||||||
# Ensure the correct permissions
|
# --------------------------------------------------------------------------
|
||||||
sudo chmod 644 /usr/share/plymouth/themes/omarchy/logo.png
|
# Boot splash
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
echo "Rebuilding initramfs..."
|
apply_splash() {
|
||||||
# Exclusively use mkinitcpio for GRUB compatibility
|
local image_path
|
||||||
sudo mkinitcpio -P
|
if [ -z "${1:-}" ]; then
|
||||||
|
image_path="$DEFAULT_IMAGE"
|
||||||
|
else
|
||||||
|
image_path=$(realpath "$1")
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Boot splash successfully updated!"
|
[ -f "$image_path" ] || die "File '$image_path' does not exist."
|
||||||
|
[ -d "$(dirname "$PLYMOUTH_LOGO")" ] || die "Plymouth omarchy theme is not installed."
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user