Compare commits

..
13 Commits
17 changed files with 3768 additions and 73 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ My current setup is built around these core components:
### Directory Structure
- **`hypr/`**: Hyprland configurations (keybindings, window rules, animations, monitor layout, lid-close display handling, and autostart). See [`hypr/keybinds.md`](hypr/keybinds.md) for custom keybindings.
- **`omarchy/`**: Omarchy 4 configuration — `shell.json` (bar layout, custom bar modules, and idle/lock timings), `extensions/` (entries added to the Omarchy menu), `hooks/` (event hooks such as retinting AGS on theme change), `plugins/` (cloned shell plugins for the bar), and `themed/` (extra theme templates). See [`omarchy/README.md`](omarchy/README.md) for the bar layout and the plugin clones.
- **`omarchy/`**: Omarchy 4 configuration — `shell.json` (bar layout, custom bar modules, and idle/lock timings), `extensions/` (entries added to the Omarchy menu), `hooks/` (event hooks such as retinting AGS on theme change), `plugins/` (forked shell plugins: the bar itself, the menu, and the workspace switcher), and `themed/` (extra theme templates). See [`omarchy/README.md`](omarchy/README.md) for the bar layout and the plugin clones.
- **`ags/`**: Custom desktop widgets built with TypeScript and GTK — media player, notification hub, quick settings, system monitor, wallpaper picker, theme picker, and Claude Code usage.
- **`scripts/`**: Global utility scripts seamlessly exposed as commands by the installer. See [`commands.md`](commands.md).
- **`wallpapers/`**: A collection of local custom wallpapers for dynamic theming. See [`wallpaper-gallery/`](wallpaper-gallery/index.md) for the full gallery (split alphabetically across multiple pages).
+12 -3
View File
@@ -66,6 +66,14 @@ function localDateKey(date: Date) {
return `${date.getFullYear()}-${month}-${day}`
}
function formatClockTime(date: Date) {
const hourOfDay = date.getHours()
const minutes = `${date.getMinutes()}`.padStart(2, "0")
const hour12 = hourOfDay % 12 === 0 ? 12 : hourOfDay % 12
const suffix = hourOfDay < 12 ? "AM" : "PM"
return `${hour12}:${minutes}${suffix}`
}
function formatResetsIn(resetsAt: string) {
const resetTime = Date.parse(resetsAt)
if (!resetTime) return ""
@@ -76,10 +84,11 @@ function formatResetsIn(resetsAt: string) {
const days = Math.floor(minutesLeft / 1440)
const hours = Math.floor((minutesLeft % 1440) / 60)
const minutes = minutesLeft % 60
const clockTime = formatClockTime(new Date(resetTime))
if (days > 0) return `Resets in ${days}d ${hours}h`
if (hours > 0) return `Resets in ${hours}h ${minutes}m`
return `Resets in ${minutes}m`
if (days > 0) return `Resets in ${days}d ${hours}h (${clockTime})`
if (hours > 0) return `Resets in ${hours}h ${minutes}m (${clockTime})`
return `Resets in ${minutes}m (${clockTime})`
}
function formatModelName(model: string) {
+1 -1
View File
@@ -450,7 +450,7 @@ function WeatherCard() {
/>
<box vertical spacing={8} visible={weather.as((w) => w.ok)}>
<StatRow icon={""} label="Temperature" value={weather.as((w) => w.temp)} />
<StatRow icon={""} label="Wind" value={weather.as((w) => w.wind)} />
<StatRow icon={""} label="Wind" value={weather.as((w) => w.wind)} />
</box>
</box>
)
+26 -26
View File
@@ -1,26 +1,26 @@
█████████████████████████████████████████████████████
█████████████████████████████████████████████████████
████ ████ ████
████ ████ ████
████ █████████████████████ ████████ ████
████ █████████████████████ ████████ ████
████ ████ ████ ████
████ ████ ████ ████
████ ████ ████ ████
████ ████ ████ ████
████ ████ ████ ████
████ ████ ████ ████
████████████ ████ ████
████████████ ████ ████
████ ████ ████ ████
████ ████ ████ ████
████ ████ ████ ████
████ ████ ████ ████
████ ████ ████ ████
████ ████ ████ ████
████ ██████████████████████████████████████ ████
████ ██████████████████████████████████████ ████
████ ████ ████
████ ████ ████
████████████████████████████ ████████████████████
████████████████████████████ ████████████████████
@@@@@@@@@@@@
@@@@ @@@@
@@@ @@@
@@@ @@@
@@@ @@
@@ @@ @@ @@
@ @@ @@ @@ @@ @@
@ @@ ##### @ @ @ @@
@ @ ##### @ @ ##### @ @@
@@ @ ### @ @ ###### @ @@
@ @ @ @ ######@@ @@
@@ @@@ @@@ @@ @@@ @
@@ @@
@@ @@
@@ @
@ @@
@@ @@
@@ @@
@ @@
@@ @@
@@ @@
@@@ @@
@@@ @@@
@@@ @@@
@@@@ @@@@
@@@@@@@@@@@@
+2
View File
@@ -205,6 +205,8 @@ check_file "$SCRIPT_DIR/omarchy/shell.json" "$HOME_DIR/.config/omarchy/shell.jso
check_file "$SCRIPT_DIR/omarchy/extensions/omarchy-menu.jsonc" "$HOME_DIR/.config/omarchy/extensions/omarchy-menu.jsonc" "omarchy/extensions/omarchy-menu.jsonc" || check_status=1
check_file "$SCRIPT_DIR/omarchy/plugins/blob.workspaces/Workspaces.qml" "$HOME_DIR/.config/omarchy/plugins/blob.workspaces/Workspaces.qml" "omarchy/plugins/blob.workspaces" || check_status=1
check_file "$SCRIPT_DIR/omarchy/plugins/blob.menu/Menu.qml" "$HOME_DIR/.config/omarchy/plugins/blob.menu/Menu.qml" "omarchy/plugins/blob.menu" || check_status=1
check_file "$SCRIPT_DIR/omarchy/plugins/blob.bar/Bar.qml" "$HOME_DIR/.config/omarchy/plugins/blob.bar/Bar.qml" "omarchy/plugins/blob.bar" || check_status=1
check_file "$SCRIPT_DIR/omarchy/plugins/blob.lock/LockView.qml" "$HOME_DIR/.config/omarchy/plugins/blob.lock/LockView.qml" "omarchy/plugins/blob.lock" || check_status=1
check_file "$SCRIPT_DIR/omarchy/hooks/theme-set" "$HOME_DIR/.config/omarchy/hooks/theme-set" "omarchy/hooks/theme-set" || check_status=1
check_file "$SCRIPT_DIR/omarchy/themed/zen.css.tpl" "$HOME_DIR/.config/omarchy/themed/zen.css.tpl" "omarchy/themed/zen.css.tpl" || check_status=1
check_file "$SCRIPT_DIR/ags/app.ts" "$HOME_DIR/.config/ags/app.ts" "ags/app.ts" || check_status=1
+119 -12
View File
@@ -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`
and `Reminder`).
## Idle timings
`idle.screensaver` and `idle.lock` are both counted from the moment the session
goes idle, not from each other. The screensaver is what paints
`branding/screensaver.txt` across every monitor, so the two values need a real
gap between them or the lock screen covers the branding as soon as it appears.
Screensaver at 300 and lock at 900 leaves ten minutes of branding before the
session locks.
Neither timer runs while `~/.local/state/omarchy/indicators/stay-awake` exists.
That file is the Stay Awake toggle, it is runtime state rather than config so
it is not tracked here, and while it is set the idle service cancels every
cycle with `idle-cycle-cancel: stay-awake`. `omarchy-toggle-idle allow-idle`
clears it. The lock screen carries the branding on its own (see
`plugins/blob.lock/` below), so this only affects the screensaver.
## Custom modules
The bar accepts arbitrary ids with `type: "command"`, which is how the two
@@ -53,24 +69,115 @@ A command module with no `exec` key is a static icon; add `exec` and
## Cloned plugins
Both were made with `omarchy plugin clone`, which copies a built-in plugin,
disables the original, and points the bar at the copy - hence the `blob.` ids
in `shell.json`.
`blob.workspaces` and `blob.menu` were made with `omarchy plugin clone`, which
copies a built-in plugin, disables the original, and points the bar at the copy
- hence the `blob.` ids in `shell.json`. `blob.bar` and `blob.lock` were copied
by hand; see below for why.
`plugins/blob.workspaces/` clones `omarchy.workspaces`. The stock widget
hardcodes workspaces 1-5 as always visible and has no setting for it, so the
clone changes that list to 1-9 to match the old waybar `persistent-workspaces`.
`plugins/blob.menu/` clones `omarchy.menu` to widen it. `cardWidth` in
`Menu.qml` is hardcoded at `Style.space(300)`; the clone raises it to 440, so
the apps menu (Super + Space) and the root menu (Super + Alt + Space) are both
wider. The two oversized menus (screen recording, font picker) keep their own
520 and are untouched. `omarchy-menu` still targets `omarchy.menu` on the CLI -
the manifest records `clonedFrom`, and the shell routes those calls here.
`plugins/blob.menu/` clones `omarchy.menu` to widen it and to let a menu row
choose where it sits. `cardWidth` in `Menu.qml` is hardcoded at
`Style.space(300)`; the clone raises it to 440, so the apps menu (Super + Space)
and the root menu (Super + Alt + Space) are both wider. The two oversized menus
(screen recording, font picker) keep their own 520 and are untouched.
`omarchy-menu` still targets `omarchy.menu` on the CLI - the manifest records
`clonedFrom`, and the shell routes those calls here.
Re-clone after an Omarchy update if the upstream widget gains something worth
picking up: `omarchy plugin clone omarchy.workspaces`, then re-apply the
one-line `workspaceIds()` change.
`mergeMenuSources` in `MenuModel.js` reads the default menu first and the user
extension second, so a row that only exists in `extensions/omarchy-menu.jsonc`
lands at the bottom of its menu with no way to move it. The clone adds a
`before:` key naming another row's id, and `applyBeforeHints` moves the row
ahead of it once both sources are merged - which is how `Blob` sits directly
under `Apps` instead of below `System`. A `before:` that names an unknown id is
ignored, and a row without one keeps its file order.
`plugins/blob.lock/` clones `omarchy.lock` to put `branding/screensaver.txt` on
the lock screen and to restyle the password field like the AGS widgets. Stock
`LockView.qml` draws a blurred wallpaper, the field, and a fingerprint hint -
there is no branding element and no config key for one, so a fork is the only
way in.
The fork is three small changes. `BrandingSource.qml` is new and holds both
file reads: the branding text, and the `color4` slot parsed out of the active
theme's `colors.toml`. `Color` resolves a palette down to `foreground`,
`background`, `accent`, `urgent`, and `muted` and never exposes `color4`, which
is the color AGS borders with. Stock `Color` reads that file once at startup
and takes theme switches over IPC, but
`~/.local/state/omarchy/current/theme` is a real directory rewritten in place
rather than a swapped symlink, so watching the file is enough here.
`Service.qml` gains only that component and two bindings on each of its two
`LockView` instances (the lock surface and the theme preview). `LockView.qml`
gains a `Text` above the field, and the field's own skin.
The styling follows `ags/style.css`: a 2px border and square corners in place
of the shell's 3px rounded outline, an `alpha(background, 0.6)` fill matching
`.qs-tile`, and the AGS border pair - `alpha(color4, 0.5)` while the field is
empty, going to a solid `accent` once there is something in it, and `urgent` on
a failed attempt. Only the geometry and the alphas are fixed; the colors come
from the active theme, so the field follows a theme change the way the AGS
widgets do. The `[lock]` tokens in a theme's `shell.toml` no longer reach the
border or the fill. `Text.Fit` scales the branding into whatever room is left
above the field, so a wider or taller `screensaver.txt` cannot run off the
screen, and a missing file leaves the lock unbranded rather than broken.
Unlike the other clones this one is load-bearing for security. The lock is a
`service` plugin, so it is enabled by its id appearing in `plugins[]` and the
original is switched off through `disabledPlugins[]` - both are needed, because
the two would otherwise register the same `lock` IPC target. There is no
fallback: a QML error means the service never loads and `omarchy-shell lock
lock` silently does nothing, which leaves the machine unlockable rather than
locked open. `journalctl --user -t omarchy-shell` names the fault as
`service plugin load failed for blob.lock`. To back out, drop `blob.lock` from
`plugins[]` and `omarchy.lock` from `disabledPlugins[]`; `cloneSourceRestores`
lists `blob.lock` so the shell restores the original by itself if the clone is
removed through `omarchy plugin remove`.
Re-copy `LockView.qml` and `Service.qml` from
`/usr/share/omarchy/shell/plugins/lock/` after an Omarchy update that touches
the lock, then re-apply the two blocks above. `BrandingSource.qml` is wholly
ours and carries over untouched.
`plugins/blob.bar/` replaces the whole bar so the clock cannot be dragged out
of the center. Omarchy 4 puts a drag-to-reorder handler on every bar module and
persists the drop into `bar.layout`; once `blob.clock` leaves the center list,
`centerAnchor` matches nothing and the center renders as a plain group. The bar
config has no setting for this, so the only lever is `canReorder` in `Bar.qml`.
The lock itself is two lines: an `anchored` property on `ModuleSlot`, and
`canReorder` gated on it, so only the module named by `centerAnchor` is pinned.
Every other widget still drags.
Three more lines are needed just to make the file loadable outside the packaged
slot. Stock `Bar.qml` declares `omarchyPath`, `barWidgetRegistry`, and
`barConfig` as `required`, which only works for the built-in bar because the
host instantiates it from an inline `Component` that sets them. A plugin bar is
loaded by URL and configured in the loader's `onLoaded`, so the required
properties are still unset at construction and the whole bar fails to build.
The copy declares them as ordinary properties defaulting to `""`/`null`, and
guards the one `barWidgetRegistry.widgets` read; `applyBarConfig` already falls
back to an empty layout, so nothing renders until the host injects the real
config a moment later.
When this happens the bar does not fall back to the stock one, it simply does
not appear: the host's `Loader.Error` branch calls a nonexistent `errorString`,
throws, and never sets `failedBarId`. If the bar ever vanishes after editing
this plugin, that is the first thing to check - `journalctl --user` will name
the offending property.
Do not run `omarchy plugin clone omarchy.bar` to refresh it. That command copies
the whole directory, including `widgets/`, whose manifests re-declare
`omarchy.workspaces`, `omarchy.tray`, and four more ids that already exist.
`Bar.qml` needs only `BarModel.js` - its widgets come from the host registry -
so the copy is just `manifest.json`, `Bar.qml`, and `BarModel.js`. A bar is
selected by `bar.id` in `shell.json` rather than by the enabled-plugin list, and
it has no disabled state: you leave one bar by naming another.
Re-copy after an Omarchy update if the upstream widget or bar gains something
worth picking up: `omarchy plugin clone omarchy.workspaces` then re-apply the
one-line `workspaceIds()` change, or copy `Bar.qml` and `BarModel.js` from
`/usr/share/omarchy/shell/plugins/bar/` and re-apply the two `canReorder` lines.
## Editing
+5 -5
View File
@@ -2,9 +2,9 @@
// Blob entries added to the Quickshell Omarchy menu (Super + Space).
// These replace the walker/elephant menus used before Omarchy 4.
"blob": { "icon": "", "label": "Blob" },
"blob.wallpaper": { "icon": "", "label": "Wallpaper", "action": "blob_wallpaper --menu", "description": "Pick a wallpaper from ~/wallpapers" },
"blob.theme": { "icon": "", "label": "Theme", "action": "blob_theme --menu", "description": "Pick a local or Omarchy color theme" },
"blob.dynamic": { "icon": "", "label": "Dynamic theme", "action": "blob_theme --dynamic", "description": "Recolor from the current wallpaper" },
"blob.glass": { "icon": "", "label": "Toggle glass", "action": "blob_glass toggle", "checked": "test -f $HOME/.local/state/omarchy/toggles/hypr/blob-glass.lua" }
"blob": { "icon": "󰖌", "label": "Blob", "before": "learn" },
"blob.wallpaper": { "icon": "󰸉", "label": "Wallpaper", "action": "blob_wallpaper --menu", "description": "Pick a wallpaper from ~/wallpapers" },
"blob.theme": { "icon": "󰢵", "label": "Theme", "action": "blob_theme --menu", "description": "Pick a local or Omarchy color theme" },
"blob.dynamic": { "icon": "󰁨", "label": "Dynamic theme", "action": "blob_theme --dynamic", "description": "Recolor from the current wallpaper" },
"blob.glass": { "icon": "󱡓", "label": "Toggle glass", "action": "blob_glass toggle", "checked": "test -f $HOME/.local/state/omarchy/toggles/hypr/blob-glass.lua" }
}
File diff suppressed because it is too large Load Diff
+231
View File
@@ -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
}
}
+17
View File
@@ -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()
}
}
+248
View File
@@ -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
}
}
}
}
+559
View File
@@ -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"
}
}
}
+18
View File
@@ -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"
}
}
+21 -1
View File
@@ -23,6 +23,7 @@ function normalizeItem(id, raw) {
return {
id: id,
parent: parent,
before: value.before || "",
kind: kind,
icon: value.icon || "",
iconFont: value.iconFont || "",
@@ -62,6 +63,23 @@ function parseMenuJsonc(raw) {
return out
}
function applyBeforeHints(items, itemOrder) {
var order = itemOrder.slice()
for (var i = 0; i < itemOrder.length; i++) {
var entry = items[itemOrder[i]]
if (!entry || !entry.before) continue
var entryIndex = order.indexOf(entry.id)
if (entryIndex < 0 || order.indexOf(entry.before) < 0) continue
order.splice(entryIndex, 1)
order.splice(order.indexOf(entry.before), 0, entry.id)
}
return order
}
function mergeMenuSources(defaultItems, userItems) {
var nextItems = ({})
var nextOrder = []
@@ -82,8 +100,10 @@ function mergeMenuSources(defaultItems, userItems) {
}
}
nextOrder = applyBeforeHints(nextItems, nextOrder)
if (!nextItems.root) {
nextItems.root = { id: "root", parent: "", kind: "menu", icon: "", iconFont: "", label: "Go", title: "", target: "", description: "", aliases: [], when: "", checked: "", action: "", provider: "" }
nextItems.root = { id: "root", parent: "", before: "", kind: "menu", icon: "", iconFont: "", label: "Go", title: "", target: "", description: "", aliases: [], when: "", checked: "", action: "", provider: "" }
nextOrder.unshift("root")
}
for (var k3 = 0; k3 < nextOrder.length; k3++) nextItems[nextOrder[k3]].order = k3
+11 -4
View File
@@ -2,9 +2,10 @@
"version": 1,
"idle": {
"screensaver": 300,
"lock": 301
"lock": 900
},
"bar": {
"id": "blob.bar",
"position": "top",
"transparent": false,
"centerAnchor": "blob.clock",
@@ -73,11 +74,17 @@
]
}
},
"plugins": [],
"plugins": [
{
"id": "blob.lock"
}
],
"disabledPlugins": [
"omarchy.menu"
"omarchy.menu",
"omarchy.lock"
],
"cloneSourceRestores": [
"blob.menu"
"blob.menu",
"blob.lock"
]
}
+632 -20
View File
@@ -1,28 +1,640 @@
#!/bin/bash
#
# blob_boot.sh - boot splash and bootloader management.
#
# blob_boot.sh [image] Apply a Plymouth boot splash image (default action)
# blob_boot.sh grub Migrate from Limine back to GRUB, detecting every OS
# --purge removes Limine in the same run instead of
# keeping it for one fallback boot
# blob_boot.sh detect Rescan for other operating systems, rebuild the menu
# blob_boot.sh cleanup Retire Limine once GRUB has been confirmed working
# blob_boot.sh status Show what this machine currently boots with
#
# This machine has a 256 MB EFI System Partition, which is why Omarchy switched
# it to a single unified kernel image. Going back to GRUB means going back to a
# separate kernel and initramfs, so every step here is careful about space.
# Default to the branding image if no argument is provided
if [ -z "$1" ]; then
IMAGE_PATH="$HOME/Documents/dotfiles/branding/boot_flash.png"
else
IMAGE_PATH=$(realpath "$1")
fi
set -euo pipefail
if [ ! -f "$IMAGE_PATH" ]; then
echo "Error: File '$IMAGE_PATH' does not exist."
exit 1
fi
DEFAULT_IMAGE="$HOME/Documents/dotfiles/branding/boot_flash.png"
PLYMOUTH_LOGO="/usr/share/plymouth/themes/omarchy/logo.png"
echo "Applying boot splash image: $IMAGE_PATH"
echo "This requires sudo privileges."
log() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
info() { printf ' %s\n' "$*"; }
warn() { printf '\033[1;33m %s\033[0m\n' "$*"; }
ok() { printf '\033[1;32m==> %s\033[0m\n' "$*"; }
die() { printf '\n\033[1;31m!!! %s\033[0m\n' "$*" >&2; exit 1; }
# Copy the image to the Plymouth theme directory
sudo cp "$IMAGE_PATH" /usr/share/plymouth/themes/omarchy/logo.png
require_sudo() {
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..."
# Exclusively use mkinitcpio for GRUB compatibility
sudo mkinitcpio -P
apply_splash() {
local image_path
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