From fa3f607705749692ecaf8c0d7aa806476d108b27 Mon Sep 17 00:00:00 2001 From: Paolo Pellicori Date: Sun, 23 Aug 2026 16:42:13 +0200 Subject: [PATCH 01/48] [Security] Keep the Windows VM password out of the RDP client's argument list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `launch_windows` passed the VM password to xfreerdp3 as `/p:"$WIN_PASS"`. `/proc//cmdline` is world-readable, so every other user on the machine could read the password for as long as the session stayed open — `ps` was enough. The arguments now go in over stdin with `/args-from:stdin`, leaving only that flag in the process list. The argument list itself is unchanged, and building it as an array also fixes the unquoted `$RDP_SCALE` expansion. --- bin/omarchy-windows-vm | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/bin/omarchy-windows-vm b/bin/omarchy-windows-vm index 793c1969..31d0230c 100755 --- a/bin/omarchy-windows-vm +++ b/bin/omarchy-windows-vm @@ -363,8 +363,31 @@ To stop: omarchy-windows-vm stop" fi # If scale is less than 130%, don't set any scale (use default 100) - # Connect with RDP in fullscreen (auto-detects resolution) - xfreerdp3 /u:"$WIN_USER" /p:"$WIN_PASS" /v:127.0.0.1:3389 -grab-keyboard /sound /microphone /clipboard /cert:ignore /title:"Windows VM - Omarchy" /dynamic-resolution /gfx:AVC444 /floatbar:sticky:off,default:visible,show:fullscreen $RDP_SCALE + RDP_ARGS=( + "/u:$WIN_USER" + "/p:$WIN_PASS" + /v:127.0.0.1:3389 + -grab-keyboard + /sound + /microphone + /clipboard + /cert:ignore + "/title:Windows VM - Omarchy" + /dynamic-resolution + /gfx:AVC444 + /floatbar:sticky:off,default:visible,show:fullscreen + ) + if [[ -n $RDP_SCALE ]]; then + RDP_ARGS+=("$RDP_SCALE") + fi + + # Connect with RDP in fullscreen (auto-detects resolution). The arguments go + # in over stdin rather than on the command line: /proc//cmdline is + # world-readable, so passing the VM password as /p:"$WIN_PASS" would show it + # to every other user on the machine for as long as the session is open. + # /args-from must stay the only argument here — FreeRDP rejects it outright + # when it is combined with any other, so new flags belong in RDP_ARGS above. + printf '%s\n' "${RDP_ARGS[@]}" | xfreerdp3 /args-from:stdin # After RDP closes, stop the container unless --keep-alive was specified if [[ $KEEP_ALIVE = "false" ]]; then From 0bec60f6e4012d95e6d0bea24ac05dbc017691b8 Mon Sep 17 00:00:00 2001 From: ogarza Date: Sun, 30 Aug 2026 05:26:46 -0400 Subject: [PATCH 02/48] Center the Quake console in a 2:1 panel instead of spanning the screen. Size it from leftover workspace gaps so it refits per monitor, and let omarchy_qconsole_ratio change the width. --- config/hypr/hyprland.lua | 5 + default/hypr/qconsole.lua | 161 ++++++++++++++++++++++--- manual/04-navigation.md | 2 + test/shell.d/hyprland-qconsole-test.sh | 88 +++++++++++++- 4 files changed, 232 insertions(+), 24 deletions(-) diff --git a/config/hypr/hyprland.lua b/config/hypr/hyprland.lua index 45c9b664..354e079c 100644 --- a/config/hypr/hyprland.lua +++ b/config/hypr/hyprland.lua @@ -10,6 +10,11 @@ dofile((os.getenv("OMARCHY_PATH") or "/usr/share/omarchy") .. "/default/hypr/boo -- keeping core window-manager bindings: -- omarchy_preinstalled_bindings = false +-- Center the quake console in a tiling box. The number is how many times +-- wider the box is than its height. 1 is a square. Comment this out for a +-- full-width drop-down. +omarchy_qconsole_ratio = 2 + -- Load Omarchy defaults. require("default.hypr.omarchy") diff --git a/default/hypr/qconsole.lua b/default/hypr/qconsole.lua index d3a36ae6..235b4675 100644 --- a/default/hypr/qconsole.lua +++ b/default/hypr/qconsole.lua @@ -4,6 +4,9 @@ -- How much of the usable screen the console covers, measured from the top. local share = 0.5 +local min_size = 64 + +local SCRATCHPAD = "special:scratchpad" -- Seed the console with the default agent the first time it opens, rather than -- at boot, so nothing is running until it is wanted. The exec rule has to pin @@ -25,20 +28,33 @@ hl.config({ -- Refitting replaces the rule in place rather than stacking a new one, but it -- still schedules a monitor and window state refresh, and monitor.focused fires --- on every hop between screens. Most of those hops do not change the number, so +-- on every hop between screens. Most of those hops do not change the gaps, so -- only write the rule when it actually moves. local covering = nil -local function cover(bottom) - if covering == bottom then - return +local function same_gaps(a, b) + return a + and b + and a.top == b.top + and a.right == b.right + and a.bottom == b.bottom + and a.left == b.left +end + +local function cover(gaps_out) + if type(gaps_out) == "number" then + gaps_out = { top = 0, right = 0, bottom = gaps_out, left = 0 } end - covering = bottom + + if same_gaps(covering, gaps_out) then + return false + end + covering = gaps_out hl.workspace_rule({ - workspace = "special:scratchpad", + workspace = SCRATCHPAD, gaps_in = 0, - gaps_out = { top = 0, right = 0, bottom = bottom, left = 0 }, + gaps_out = gaps_out, -- Nothing to highlight in a console that is only ever focused when it is -- open, and the active border reads as a stray frame around a panel that @@ -47,39 +63,148 @@ local function cover(bottom) on_created_empty = seed, }) + + return true +end + +local function reserved_edges(monitor) + local reserved = monitor.reserved + if type(reserved) ~= "table" then + return { top = 0, right = 0, bottom = 0, left = 0 } + end + + return { + top = reserved.top or 0, + right = reserved.right or 0, + bottom = reserved.bottom or 0, + left = reserved.left or 0, + } +end + +-- A positive omarchy_qconsole_ratio (set in hyprland.lua before defaults load) +-- centers the console in a tiling box that many times wider than it is tall. +-- Values below 1 clamp to a square. Unset keeps the full-width drop-down. +local function box_ratio() + local ratio = _G.omarchy_qconsole_ratio + if type(ratio) == "number" and ratio > 0 then + return math.max(1, ratio) + end + return nil +end + +local function is_scratchpad(ws) + return ws and (ws.name == SCRATCHPAD or ws.name == "scratchpad") end -- Sizing the console with a window rule would freeze it at whatever the screen -- measured when it first opened, because Hyprland resolves those expressions -- once, as the window maps. Rescaling the monitor afterwards would leave a -- console that is no longer half of anything. Gaps are re-applied by the layout --- instead, so the console is sized by the gap left underneath it and that gap --- is recomputed whenever the monitor layout changes. -local function fit() - local monitor = hl.get_active_monitor() +-- instead, so the console is sized by the leftover area and that area is +-- recomputed whenever the monitor it is opening on changes. +local function fit(monitor) + monitor = monitor or hl.get_active_monitor() -- A monitor handle whose output has gone away answers nil to every field, and -- layout changes are exactly when that happens, so this also covers reading -- height and reserved below. if not monitor or not monitor.scale or monitor.scale <= 0 then - return + return false end -- Monitor dimensions are in physical pixels; gaps are logical, so the scale -- has to come out before the reserved area (already logical) comes off. - local reserved = monitor.reserved - local usable = monitor.height / monitor.scale - reserved.top - reserved.bottom + local reserved = reserved_edges(monitor) + local usable_h = monitor.height / monitor.scale - reserved.top - reserved.bottom + local usable_w = nil + if monitor.width then + usable_w = monitor.width / monitor.scale - reserved.left - reserved.right + end - cover(math.max(0, math.floor(usable * (1 - share)))) + local ratio = box_ratio() + local h = math.max(min_size, math.floor(usable_h * share)) + local side = 0 + local bottom = math.max(0, math.floor(usable_h - h)) + + if ratio and usable_w then + local w = math.floor(h * ratio) + if w > usable_w then + w = math.floor(usable_w) + end + side = math.max(0, math.floor((usable_w - w) / 2)) + if usable_w - (side * 2) < min_size then + side = math.max(0, math.floor((usable_w - min_size) / 2)) + end + end + + if usable_h - bottom < min_size then + bottom = math.max(0, math.floor(usable_h - min_size)) + end + + return cover({ top = 0, right = side, bottom = bottom, left = side }) +end + +local function apply_now() + if hl.exec_scheduled_prop_refresh_immediately then + hl.exec_scheduled_prop_refresh_immediately() + end +end + +local function scratchpad_on_other_monitor(mon) + if not hl.get_workspace or not mon then + return false + end + + local ws = hl.get_workspace(SCRATCHPAD) + if not is_scratchpad(ws) or not ws.visible or not ws.monitor or not ws.monitor.name or not mon.name then + return false + end + + return ws.monitor.name ~= mon.name end -- Until a monitor can be read, cover the whole work area rather than leaving -- the console unruled, so it is never seeded without its placement. -cover(0) +cover({ top = 0, right = 0, bottom = 0, left = 0 }) fit() -hl.on("monitor.layout_changed", fit) -hl.on("monitor.focused", fit) +hl.on("monitor.layout_changed", function() + local ws = hl.get_workspace and hl.get_workspace(SCRATCHPAD) + if is_scratchpad(ws) and ws.visible and ws.monitor then + fit(ws.monitor) + else + fit() + end +end) + +-- follow_mouse hops fire this; do not rewrite an open console to a different +-- output's gaps (that is what zeroed the window on the 1080p screen). +hl.on("monitor.focused", function(mon) + if scratchpad_on_other_monitor(mon) then + return + end + fit(mon) +end) + +-- Special workspaces toggle on the monitor they open on, not whichever output +-- last happened to be focused when the rule was written. +hl.on("workspace.special_active", function(ws, mon) + if not is_scratchpad(ws) then + return + end + if fit(mon) then + apply_now() + end +end) + +hl.on("workspace.move_to_monitor", function(ws, mon) + if not is_scratchpad(ws) then + return + end + if fit(mon) then + apply_now() + end +end) -- The direction names the edge the offset is measured from, not where the -- workspace goes: "slide top" drops it down into view, and "slide bottom" diff --git a/manual/04-navigation.md b/manual/04-navigation.md index 549833bf..1b62b0df 100644 --- a/manual/04-navigation.md +++ b/manual/04-navigation.md @@ -66,6 +66,8 @@ Finally, there's a special scratchpad workspace that drops down over whatever wo It works especially well for a terminal running an agent, or for controls you want to interact with quickly without leaving the current workspace. To move a window off the scratchpad, send it directly to another workspace with something like `Super + Shift + 1`. +By default the scratchpad drops down as a centered panel. Open `~/.config/hypr/hyprland.lua` if you want to change how wide that panel is (`omarchy_qconsole_ratio` is near the top). `1` makes a square; `2` makes a panel twice as wide as it is tall; `3` or `4` is wider still. Anything smaller than `1` still gives you a square. Comment the line out if you'd rather have it span the full width of the screen. + ### It takes some getting used to! It takes a little while to get used to navigating your desktop like this, but once you do, it'll be hard to go back to a traditional mouse-driven desktop experience! diff --git a/test/shell.d/hyprland-qconsole-test.sh b/test/shell.d/hyprland-qconsole-test.sh index 57bdea8d..9360e3c8 100755 --- a/test/shell.d/hyprland-qconsole-test.sh +++ b/test/shell.d/hyprland-qconsole-test.sh @@ -10,9 +10,10 @@ require_command lua # scaled display, so it is worth pinning down. # base-test.sh does not set -e, so the assertions have to fail the file # themselves rather than leaving the pass below to run regardless. -OMARCHY_PATH="$ROOT" lua - <<'LUA' || fail "the console covers half the work area at any monitor scale" +OMARCHY_PATH="$ROOT" lua - <<'LUA' || fail "the console is a half-height 2:1 panel by default" local rules, handlers = {}, {} local monitor = nil +local workspace = nil hl = { config = function() end, @@ -20,8 +21,14 @@ hl = { workspace_rule = function(rule) table.insert(rules, rule) end, on = function(event, callback) handlers[event] = callback end, get_active_monitor = function() return monitor end, + get_workspace = function() return workspace end, + exec_scheduled_prop_refresh_immediately = function() end, } +-- Same default as config/hypr/hyprland.lua: a centered panel twice as wide as +-- it is tall. Commenting that assignment out is what restores full width. +omarchy_qconsole_ratio = 2 + dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") require("default.hypr.qconsole") @@ -29,6 +36,11 @@ local function current() return rules[#rules] end +local function gaps() + local g = current().gaps_out + return g.top, g.right, g.bottom, g.left +end + -- Config loads before the outputs are up, so the first pass has no monitor to -- read. It still has to leave a rule behind, or the console would open unseeded. assert(#rules > 0, "console is ruled even before a monitor can be read") @@ -44,6 +56,7 @@ local function rescale(height, scale, bar) end -- Same panel, same logical size, different scale: the console must not care. +-- These fixtures omit width, so the ratio cannot inset the sides yet. assert(rescale(1080, 1, 40) == 520, "half of a 1080p work area, unscaled") assert(rescale(2160, 2, 40) == 520, "the same half once the monitor is scaled 2x") assert(rescale(2160, 1.5, 40) == 700, "and at a fractional scale") @@ -52,11 +65,10 @@ assert(rescale(2160, 1.5, 40) == 700, "and at a fractional scale") -- console short. assert(rescale(1440, 1, 0) == 720, "a monitor with nothing reserved") --- The console stays flush with the top and the sides, the way a Quake console --- drops in, and keeps its seed across every refit. local final = current() -assert(final.gaps_out.top == 0 and final.gaps_out.left == 0 and final.gaps_out.right == 0, - "the console is flush to the top and sides") +assert(final.gaps_out.top == 0, "the console stays flush with the top") +assert(final.gaps_out.left == 0 and final.gaps_out.right == 0, + "without a width the sides stay flush") assert(final.on_created_empty:find("omarchy%-agent"), "refitting keeps the console seeded") assert(final.no_border == true, "the console drops the active window border") @@ -86,5 +98,69 @@ monitor.scale = 2 handlers["monitor.layout_changed"]() assert(#rules == written + 1, "a real change still rewrites it") assert(current().gaps_out.bottom == 360, "and lands on half the rescaled screen") + +-- Default ratio: 16:9 is a centered 2:1 panel, not a full-width drop-down. +monitor = { width = 1920, height = 1080, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +local top, right, bottom, left = gaps() +assert(top == 0 and left == 420 and right == 420 and bottom == 540, "16:9 default is a 1080x540 panel") + +local dell = { name = "DP-1", width = 6144, height = 2560, scale = 1, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +monitor = dell +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 1807 and right == 1807 and bottom == 1265, "the same 2:1 panel on 6K") + +-- Same logical box at scale 2x (physical 12288x5120). +-- It checks that scale does not change the panel's logical size. +monitor = { name = "DP-1", width = 12288, height = 5120, scale = 2, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 1807 and right == 1807 and bottom == 1265, "the 6K box is in logical pixels") + +-- Acer 1920x1080, bar 30, same ratio. Opening here after a 6K fit must rewrite; +-- leaving 6K side gaps would make leftover width negative. +local acer = { name = "HDMI-A-1", width = 1920, height = 1080, scale = 1, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +monitor = dell +handlers["monitor.layout_changed"]() +handlers["workspace.special_active"]({ name = "special:scratchpad" }, acer) +top, right, bottom, left = gaps() +assert(left == 435 and right == 435 and bottom == 525, "opening on 1080p after a 6K fit resizes the 2:1 box") +assert(1920 - left - right > 0 and 1080 - 30 - bottom > 0, "1080p leftover is never negative") + +-- follow_mouse onto the 6K while the console is already showing on 1080p must +-- not steal the global rule (that is what oversized the Dell after a hop). +workspace = { name = "special:scratchpad", visible = true, monitor = acer } +written = #rules +handlers["monitor.focused"](dell) +assert(#rules == written, "focus on another output does not rewrite an open console") +workspace = nil + +-- Cache: same 1440p height, 16:9 vs 21:9. Sides change even when bottom does not. +monitor = { width = 2560, height = 1440, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +written = #rules +monitor = { width = 3440, height = 1440, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +assert(#rules == written + 1, "a same-height ultrawide hop still rewrites the sides") +top, right, bottom, left = gaps() +assert(left == 1000 and right == 1000 and bottom == 720, "3440x1440 at ratio 2 is a 2:1 box") + +omarchy_qconsole_ratio = 1 +monitor = { width = 1920, height = 1080, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 690 and right == 690 and bottom == 540, "ratio 1 is a square") + +omarchy_qconsole_ratio = 0.5 +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 690 and right == 690 and bottom == 540, "a ratio below 1 clamps to a square") + +omarchy_qconsole_ratio = nil +monitor = dell +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 0 and right == 0 and bottom == 1265, "clearing the ratio restores full width") LUA -pass "the console covers half the work area at any monitor scale" +pass "the console is a half-height 2:1 panel by default" From a59afb9659ca0d2a33316d510fac70755d9752f1 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 30 Aug 2026 14:50:40 +0200 Subject: [PATCH 03/48] Give the width back when a second app joins the console The scratchpad spanned the whole screen whatever was on it, which reads as a band rather than a console when it holds the single agent it is usually opened for. It now sizes to a centered panel twice as wide as it is tall, and hands the full width back as soon as a second app is on the scratchpad: two windows splitting a half-width column is worse than the band this replaced. The panel is still drawn with workspace gaps rather than a window rule, because Hyprland resolves a rule's size once as the window maps and the console has to survive a rescale. It is always flush with the top and always centered, so two numbers describe it, and the memo that suppresses no-op rewrites compares those rather than four edges. The window count is read on window.open and window.destroy, the two events that run after the workspace's count has settled; window.close and window.move_to_workspace both still count the window on its way out, so refitting from either reads one too many and strands the console at full width. The count is only chased while the console is on screen, since a hidden one is refitted on its way in by workspace.special_active, and every window opened anywhere on the desktop would otherwise rewrite the rule. Monitor width and height are the panel's own pixels, so an output turned on its side still reports them the way the panel is built rather than the way it hangs. The odd transforms are the quarter turns and those swap the work area: without that a rotated 1920x1080 sized its console from 1920x1080 instead of 1080x1920 and left a 214x1367 sliver. Co-Authored-By: Claude Opus 5 (1M context) Co-Authored-By: Codex XHigh --- config/hypr/hyprland.lua | 5 - default/hypr/qconsole.lua | 211 +++++++++++-------------- manual/04-navigation.md | 2 +- test/shell.d/hyprland-qconsole-test.sh | 186 +++++++++++++++------- 4 files changed, 220 insertions(+), 184 deletions(-) diff --git a/config/hypr/hyprland.lua b/config/hypr/hyprland.lua index 354e079c..45c9b664 100644 --- a/config/hypr/hyprland.lua +++ b/config/hypr/hyprland.lua @@ -10,11 +10,6 @@ dofile((os.getenv("OMARCHY_PATH") or "/usr/share/omarchy") .. "/default/hypr/boo -- keeping core window-manager bindings: -- omarchy_preinstalled_bindings = false --- Center the quake console in a tiling box. The number is how many times --- wider the box is than its height. 1 is a square. Comment this out for a --- full-width drop-down. -omarchy_qconsole_ratio = 2 - -- Load Omarchy defaults. require("default.hypr.omarchy") diff --git a/default/hypr/qconsole.lua b/default/hypr/qconsole.lua index 235b4675..cb248f4c 100644 --- a/default/hypr/qconsole.lua +++ b/default/hypr/qconsole.lua @@ -4,7 +4,12 @@ -- How much of the usable screen the console covers, measured from the top. local share = 0.5 -local min_size = 64 + +-- A console holding a single window is boxed into a centered panel this many +-- times wider than it is tall, rather than stretched the width of the screen. +-- A second app on the scratchpad gets the full width back: two windows splitting +-- a half-width column is worse than the band this replaced. +local box = 2 local SCRATCHPAD = "special:scratchpad" @@ -26,35 +31,25 @@ hl.config({ }, }) +-- The panel is always flush with the top and always centered, so two numbers +-- describe it: the gap down each side and the gap underneath. +-- -- Refitting replaces the rule in place rather than stacking a new one, but it -- still schedules a monitor and window state refresh, and monitor.focused fires -- on every hop between screens. Most of those hops do not change the gaps, so -- only write the rule when it actually moves. -local covering = nil +local beside, below = nil, nil -local function same_gaps(a, b) - return a - and b - and a.top == b.top - and a.right == b.right - and a.bottom == b.bottom - and a.left == b.left -end - -local function cover(gaps_out) - if type(gaps_out) == "number" then - gaps_out = { top = 0, right = 0, bottom = gaps_out, left = 0 } - end - - if same_gaps(covering, gaps_out) then +local function cover(side, bottom) + if beside == side and below == bottom then return false end - covering = gaps_out + beside, below = side, bottom hl.workspace_rule({ workspace = SCRATCHPAD, gaps_in = 0, - gaps_out = gaps_out, + gaps_out = { top = 0, right = side, bottom = bottom, left = side }, -- Nothing to highlight in a console that is only ever focused when it is -- open, and the active border reads as a stray frame around a panel that @@ -67,145 +62,121 @@ local function cover(gaps_out) return true end -local function reserved_edges(monitor) - local reserved = monitor.reserved - if type(reserved) ~= "table" then - return { top = 0, right = 0, bottom = 0, left = 0 } - end - - return { - top = reserved.top or 0, - right = reserved.right or 0, - bottom = reserved.bottom or 0, - left = reserved.left or 0, - } -end - --- A positive omarchy_qconsole_ratio (set in hyprland.lua before defaults load) --- centers the console in a tiling box that many times wider than it is tall. --- Values below 1 clamp to a square. Unset keeps the full-width drop-down. -local function box_ratio() - local ratio = _G.omarchy_qconsole_ratio - if type(ratio) == "number" and ratio > 0 then - return math.max(1, ratio) - end - return nil -end - -local function is_scratchpad(ws) - return ws and (ws.name == SCRATCHPAD or ws.name == "scratchpad") +-- One window reads as a console and gets the panel. A second app has turned the +-- scratchpad into a workspace, and a workspace wants the whole width. +local function alone() + local ws = hl.get_workspace(SCRATCHPAD) + return not ws or ws.windows <= 1 end -- Sizing the console with a window rule would freeze it at whatever the screen -- measured when it first opened, because Hyprland resolves those expressions -- once, as the window maps. Rescaling the monitor afterwards would leave a -- console that is no longer half of anything. Gaps are re-applied by the layout --- instead, so the console is sized by the leftover area and that area is --- recomputed whenever the monitor it is opening on changes. +-- instead, so the console is sized by the area left around it, and that area is +-- recomputed whenever the monitor it opens on changes. local function fit(monitor) - monitor = monitor or hl.get_active_monitor() - -- A monitor handle whose output has gone away answers nil to every field, and -- layout changes are exactly when that happens, so this also covers reading - -- height and reserved below. + -- width, height and reserved below. if not monitor or not monitor.scale or monitor.scale <= 0 then return false end + -- Width and height are the panel's own pixels, so a monitor turned on its + -- side still reports them the way the panel is built. The odd transforms are + -- the quarter turns, and those are the ones that swap the work area. + local width, height = monitor.width, monitor.height + if monitor.transform % 2 == 1 then + width, height = height, width + end + -- Monitor dimensions are in physical pixels; gaps are logical, so the scale -- has to come out before the reserved area (already logical) comes off. - local reserved = reserved_edges(monitor) - local usable_h = monitor.height / monitor.scale - reserved.top - reserved.bottom - local usable_w = nil - if monitor.width then - usable_w = monitor.width / monitor.scale - reserved.left - reserved.right + local reserved = monitor.reserved + height = height / monitor.scale - reserved.top - reserved.bottom + width = width / monitor.scale - reserved.left - reserved.right + + local tall = math.floor(height * share) + local wide = width + if alone() then + wide = math.min(width, tall * box) end - local ratio = box_ratio() - local h = math.max(min_size, math.floor(usable_h * share)) - local side = 0 - local bottom = math.max(0, math.floor(usable_h - h)) - - if ratio and usable_w then - local w = math.floor(h * ratio) - if w > usable_w then - w = math.floor(usable_w) - end - side = math.max(0, math.floor((usable_w - w) / 2)) - if usable_w - (side * 2) < min_size then - side = math.max(0, math.floor((usable_w - min_size) / 2)) - end - end - - if usable_h - bottom < min_size then - bottom = math.max(0, math.floor(usable_h - min_size)) - end - - return cover({ top = 0, right = side, bottom = bottom, left = side }) + return cover(math.floor((width - wide) / 2), math.floor(height - tall)) end -local function apply_now() - if hl.exec_scheduled_prop_refresh_immediately then +-- The console keeps the geometry of the output it is open on: a follow_mouse hop +-- onto another screen must not resize a console that is already showing. While +-- it is hidden there is nothing to size but the output that will show it next. +local function console_monitor() + local ws = hl.get_workspace(SCRATCHPAD) + local mon = ws and ws.visible and ws.monitor + + if mon and mon.scale and mon.scale > 0 then + return mon + end + + return hl.get_active_monitor() +end + +local function refit(monitor) + if fit(monitor or console_monitor()) then + -- Land the new gaps in this pass rather than a frame later, so the console + -- does not visibly resize itself once it has already dropped down. hl.exec_scheduled_prop_refresh_immediately() end end -local function scratchpad_on_other_monitor(mon) - if not hl.get_workspace or not mon then - return false - end - - local ws = hl.get_workspace(SCRATCHPAD) - if not is_scratchpad(ws) or not ws.visible or not ws.monitor or not ws.monitor.name or not mon.name then - return false - end - - return ws.monitor.name ~= mon.name -end - -- Until a monitor can be read, cover the whole work area rather than leaving --- the console unruled, so it is never seeded without its placement. -cover({ top = 0, right = 0, bottom = 0, left = 0 }) -fit() +-- the console unruled, so it is never seeded without its placement. A reload +-- runs this again with the console already on screen, so it starts from the +-- output the console is on rather than whichever one the pointer is over. +cover(0, 0) +fit(console_monitor()) hl.on("monitor.layout_changed", function() - local ws = hl.get_workspace and hl.get_workspace(SCRATCHPAD) - if is_scratchpad(ws) and ws.visible and ws.monitor then - fit(ws.monitor) - else - fit() - end + refit() end) --- follow_mouse hops fire this; do not rewrite an open console to a different --- output's gaps (that is what zeroed the window on the 1080p screen). -hl.on("monitor.focused", function(mon) - if scratchpad_on_other_monitor(mon) then - return - end - fit(mon) +hl.on("monitor.focused", function() + refit() end) --- Special workspaces toggle on the monitor they open on, not whichever output --- last happened to be focused when the rule was written. +-- Special workspaces open on the monitor they are toggled on, not on whichever +-- output last happened to be focused when the rule was written, so these two +-- take the monitor they are handed rather than looking one up. hl.on("workspace.special_active", function(ws, mon) - if not is_scratchpad(ws) then - return - end - if fit(mon) then - apply_now() + if ws and ws.name == SCRATCHPAD then + refit(mon) end end) hl.on("workspace.move_to_monitor", function(ws, mon) - if not is_scratchpad(ws) then - return - end - if fit(mon) then - apply_now() + if ws and ws.name == SCRATCHPAD then + refit(mon) end end) +-- The panel is only centered while the console holds one window, so the count +-- has to be rechecked as apps come and go. These are the two events that run +-- after the workspace's count has already moved: window.close and +-- window.move_to_workspace still count the window on its way out, and refitting +-- from those would read one too many and leave the console full width. +-- +-- Only while it is on screen, though. A hidden console is refitted on its way in +-- by workspace.special_active, and every window opened anywhere on the desktop +-- would otherwise rewrite the rule. +local function recount() + local ws = hl.get_workspace(SCRATCHPAD) + if ws and ws.visible then + refit() + end +end + +hl.on("window.open", recount) +hl.on("window.destroy", recount) + -- The direction names the edge the offset is measured from, not where the -- workspace goes: "slide top" drops it down into view, and "slide bottom" -- retracts it back up the way a Quake console does. diff --git a/manual/04-navigation.md b/manual/04-navigation.md index 1b62b0df..0b3404ff 100644 --- a/manual/04-navigation.md +++ b/manual/04-navigation.md @@ -66,7 +66,7 @@ Finally, there's a special scratchpad workspace that drops down over whatever wo It works especially well for a terminal running an agent, or for controls you want to interact with quickly without leaving the current workspace. To move a window off the scratchpad, send it directly to another workspace with something like `Super + Shift + 1`. -By default the scratchpad drops down as a centered panel. Open `~/.config/hypr/hyprland.lua` if you want to change how wide that panel is (`omarchy_qconsole_ratio` is near the top). `1` makes a square; `2` makes a panel twice as wide as it is tall; `3` or `4` is wider still. Anything smaller than `1` still gives you a square. Comment the line out if you'd rather have it span the full width of the screen. +While the scratchpad holds a single window, it drops down as a centered panel rather than spanning the screen. Put a second app on it and it goes back to the full width, so the two have room to sit side by side. ### It takes some getting used to! diff --git a/test/shell.d/hyprland-qconsole-test.sh b/test/shell.d/hyprland-qconsole-test.sh index 9360e3c8..c5123d03 100755 --- a/test/shell.d/hyprland-qconsole-test.sh +++ b/test/shell.d/hyprland-qconsole-test.sh @@ -4,13 +4,13 @@ source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" require_command lua -# The console is sized by the gap underneath it, recomputed from the monitor, +# The console is sized by the gaps around it, recomputed from the monitor, # because a window rule's size would freeze at whatever the screen measured when -# the console first opened. The arithmetic is what keeps it half a screen on a -# scaled display, so it is worth pinning down. +# the console first opened. The arithmetic is what keeps it a half-height panel +# on a scaled display, so it is worth pinning down. # base-test.sh does not set -e, so the assertions have to fail the file # themselves rather than leaving the pass below to run regardless. -OMARCHY_PATH="$ROOT" lua - <<'LUA' || fail "the console is a half-height 2:1 panel by default" +OMARCHY_PATH="$ROOT" lua - <<'LUA' || fail "the console is a centered panel until a second app joins it" local rules, handlers = {}, {} local monitor = nil local workspace = nil @@ -25,10 +25,6 @@ hl = { exec_scheduled_prop_refresh_immediately = function() end, } --- Same default as config/hypr/hyprland.lua: a centered panel twice as wide as --- it is tall. Commenting that assignment out is what restores full width. -omarchy_qconsole_ratio = 2 - dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") require("default.hypr.qconsole") @@ -50,13 +46,12 @@ assert(current().on_created_empty:find("^%[workspace special:scratchpad silent%] assert(current().workspace == "special:scratchpad") local function rescale(height, scale, bar) - monitor = { height = height, scale = scale, reserved = { top = bar, bottom = 0, left = 0, right = 0 } } + monitor = { width = 1920, height = height, scale = scale, transform = 0, reserved = { top = bar, bottom = 0, left = 0, right = 0 } } handlers["monitor.layout_changed"]() return current().gaps_out.bottom end -- Same panel, same logical size, different scale: the console must not care. --- These fixtures omit width, so the ratio cannot inset the sides yet. assert(rescale(1080, 1, 40) == 520, "half of a 1080p work area, unscaled") assert(rescale(2160, 2, 40) == 520, "the same half once the monitor is scaled 2x") assert(rescale(2160, 1.5, 40) == 700, "and at a fractional scale") @@ -66,9 +61,8 @@ assert(rescale(2160, 1.5, 40) == 700, "and at a fractional scale") assert(rescale(1440, 1, 0) == 720, "a monitor with nothing reserved") local final = current() -assert(final.gaps_out.top == 0, "the console stays flush with the top") -assert(final.gaps_out.left == 0 and final.gaps_out.right == 0, - "without a width the sides stay flush") +assert(final.gaps_out.top == 0, "the console stays flush with the top, the way a drop-down arrives") +assert(final.gaps_out.left == final.gaps_out.right, "the panel is centered") assert(final.on_created_empty:find("omarchy%-agent"), "refitting keeps the console seeded") assert(final.no_border == true, "the console drops the active window border") @@ -81,86 +75,162 @@ assert(current().gaps_out.bottom == before, "an absent monitor leaves the consol -- A monitor handle outliving its output answers nil to everything, which is -- what a layout change looks like mid-flight. Reading height or reserved off -- that would throw, so the scale guard has to catch it first. -monitor = setmetatable({}, { __index = function() return nil end }) +local expired = setmetatable({}, { __index = function() return nil end }) +monitor = expired handlers["monitor.layout_changed"]() assert(current().gaps_out.bottom == before, "an expired monitor handle is not read to pieces") -- Refitting to the size it already is would still cost a state refresh, and -- monitor.focused fires on every hop between screens. -monitor = { height = 1440, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +monitor = { width = 2560, height = 1440, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } handlers["monitor.layout_changed"]() local written = #rules handlers["monitor.focused"]() handlers["monitor.layout_changed"]() assert(#rules == written, "refitting to the same size does not rewrite the rule") -monitor.scale = 2 -handlers["monitor.layout_changed"]() -assert(#rules == written + 1, "a real change still rewrites it") -assert(current().gaps_out.bottom == 360, "and lands on half the rescaled screen") - --- Default ratio: 16:9 is a centered 2:1 panel, not a full-width drop-down. -monitor = { width = 1920, height = 1080, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +-- A centered 2:1 panel, not a full-width drop-down. +monitor = { width = 1920, height = 1080, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } handlers["monitor.layout_changed"]() local top, right, bottom, left = gaps() -assert(top == 0 and left == 420 and right == 420 and bottom == 540, "16:9 default is a 1080x540 panel") +assert(top == 0 and left == 420 and right == 420 and bottom == 540, "16:9 leaves a 1080x540 panel") -local dell = { name = "DP-1", width = 6144, height = 2560, scale = 1, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +local dell = { name = "DP-1", width = 6144, height = 2560, scale = 1, transform = 0, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } monitor = dell handlers["monitor.layout_changed"]() top, right, bottom, left = gaps() assert(left == 1807 and right == 1807 and bottom == 1265, "the same 2:1 panel on 6K") --- Same logical box at scale 2x (physical 12288x5120). --- It checks that scale does not change the panel's logical size. -monitor = { name = "DP-1", width = 12288, height = 5120, scale = 2, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +-- Same logical box at scale 2x (physical 12288x5120): scale does not change the +-- panel's logical size. +monitor = { name = "DP-1", width = 12288, height = 5120, scale = 2, transform = 0, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } handlers["monitor.layout_changed"]() top, right, bottom, left = gaps() assert(left == 1807 and right == 1807 and bottom == 1265, "the 6K box is in logical pixels") --- Acer 1920x1080, bar 30, same ratio. Opening here after a 6K fit must rewrite; --- leaving 6K side gaps would make leftover width negative. -local acer = { name = "HDMI-A-1", width = 1920, height = 1080, scale = 1, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +-- Same height, different width: the sides have to move even though the bottom +-- gap is identical, so the cache cannot key on height alone. +monitor = { width = 2560, height = 1440, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +written = #rules +monitor = { width = 3440, height = 1440, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +assert(#rules == written + 1, "a same-height ultrawide hop still rewrites the sides") +top, right, bottom, left = gaps() +assert(left == 1000 and right == 1000 and bottom == 720, "3440x1440 leaves a 1440x720 box") + +-- A panel wider than the screen is just the screen: a portrait monitor has no +-- room for a 2:1 box and falls back to the full width rather than a negative gap. +monitor = { width = 1080, height = 1920, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 0 and right == 0 and bottom == 960, "a portrait monitor keeps the full width") +assert(left >= 0 and right >= 0 and bottom >= 0, "gaps are never negative") + +-- A monitor turned on its side still reports the panel's own pixels, so the +-- work area has to be turned with it. Measured against Hyprland 0.56.2: a +-- rotated 1920x1080 lays its windows out in 1080x1920 while width and height +-- still read 1920 and 1080. Quarter turns are the odd transforms; a half turn +-- leaves the shape alone. +monitor = { width = 1920, height = 1080, scale = 1, transform = 1, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 0 and right == 0 and bottom == 945, "a quarter-turned monitor is sized portrait") + +monitor = { width = 1920, height = 1080, scale = 1, transform = 3, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 0 and right == 0 and bottom == 945, "and so is the other quarter turn") + +monitor = { width = 1920, height = 1080, scale = 1, transform = 2, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435 and bottom == 525, "a half turn is still landscape") + +-- Special workspaces open on the monitor they are toggled on, not on whichever +-- output was focused when the rule was last written. Opening on 1080p after a +-- 6K fit has to resize the box. +local acer = { name = "HDMI-A-1", width = 1920, height = 1080, scale = 1, transform = 0, reserved = { top = 30, bottom = 0, left = 0, right = 0 } } monitor = dell handlers["monitor.layout_changed"]() handlers["workspace.special_active"]({ name = "special:scratchpad" }, acer) top, right, bottom, left = gaps() -assert(left == 435 and right == 435 and bottom == 525, "opening on 1080p after a 6K fit resizes the 2:1 box") +assert(left == 435 and right == 435 and bottom == 525, "opening on 1080p after a 6K fit resizes the box") assert(1920 - left - right > 0 and 1080 - 30 - bottom > 0, "1080p leftover is never negative") -- follow_mouse onto the 6K while the console is already showing on 1080p must --- not steal the global rule (that is what oversized the Dell after a hop). -workspace = { name = "special:scratchpad", visible = true, monitor = acer } +-- not steal the rule; that is what oversized the Dell after a hop. +workspace = { name = "special:scratchpad", visible = true, monitor = acer, windows = 1 } +monitor = dell written = #rules handlers["monitor.focused"](dell) assert(#rules == written, "focus on another output does not rewrite an open console") -workspace = nil --- Cache: same 1440p height, 16:9 vs 21:9. Sides change even when bottom does not. -monitor = { width = 2560, height = 1440, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } -handlers["monitor.layout_changed"]() -written = #rules -monitor = { width = 3440, height = 1440, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } -handlers["monitor.layout_changed"]() -assert(#rules == written + 1, "a same-height ultrawide hop still rewrites the sides") -top, right, bottom, left = gaps() -assert(left == 1000 and right == 1000 and bottom == 720, "3440x1440 at ratio 2 is a 2:1 box") - -omarchy_qconsole_ratio = 1 -monitor = { width = 1920, height = 1080, scale = 1, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } -handlers["monitor.layout_changed"]() -top, right, bottom, left = gaps() -assert(left == 690 and right == 690 and bottom == 540, "ratio 1 is a square") - -omarchy_qconsole_ratio = 0.5 -handlers["monitor.layout_changed"]() -top, right, bottom, left = gaps() -assert(left == 690 and right == 690 and bottom == 540, "a ratio below 1 clamps to a square") - -omarchy_qconsole_ratio = nil +-- The output the console is showing on can go away mid-layout-change. Its +-- handle then answers nil to everything, and preferring it blindly would leave +-- the console stranded at the gaps of the monitor that is gone. The rule is +-- still the 1080p one here, so only refitting on the Dell can satisfy this. +workspace = { name = "special:scratchpad", visible = true, monitor = expired, windows = 1 } monitor = dell handlers["monitor.layout_changed"]() top, right, bottom, left = gaps() -assert(left == 0 and right == 0 and bottom == 1265, "clearing the ratio restores full width") +assert(left == 1807 and right == 1807 and bottom == 1265, + "a console whose output vanished refits on the monitor that is still there") + +-- Back onto the 1080p panel for the window-count checks below. +workspace = { name = "special:scratchpad", visible = true, monitor = acer, windows = 1 } +monitor = acer +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435 and bottom == 525, "and refits again once it is back on a live output") + +-- One window reads as a console and keeps the panel. A second app has turned +-- the scratchpad into a workspace, and a workspace wants the whole width. +workspace = { name = "special:scratchpad", visible = true, monitor = acer, windows = 2 } +handlers["window.open"]() +top, right, bottom, left = gaps() +assert(left == 0 and right == 0, "a second app on the scratchpad restores the full width") +assert(bottom == 525, "and the console keeps its half-height drop") + +workspace.windows = 3 +written = #rules +handlers["window.open"]() +assert(#rules == written, "a third app changes nothing that is already full width") + +workspace.windows = 1 +handlers["window.destroy"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435, "closing back down to one window recenters the panel") + +-- An empty scratchpad is about to be seeded with a single agent, so it is sized +-- as a console rather than as a workspace. +workspace.windows = 0 +handlers["window.destroy"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435, "an empty console is still a console") + +-- A hidden console is refitted on its way back in, so the count does not have to +-- be chased while it is off screen; every window on the desktop would otherwise +-- rewrite the rule. +workspace = { name = "special:scratchpad", visible = false, monitor = acer, windows = 4 } +monitor = dell +written = #rules +handlers["window.open"]() +assert(#rules == written, "a window opening elsewhere does not rewrite a hidden console") + +-- A scratchpad nothing has opened yet has no workspace to read at all, and is +-- sized as the console the seed is about to put a single agent into. +workspace = nil +monitor = { width = 1920, height = 1080, scale = 1, transform = 0, reserved = { top = 0, bottom = 0, left = 0, right = 0 } } +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 420 and right == 420 and bottom == 540, "a scratchpad that does not exist yet is sized as a console") + +-- window.close and window.move_to_workspace both run while the workspace still +-- counts the window that is leaving, so a refit from either reads one too many +-- and strands the console at full width. window.open and window.destroy are the +-- two that run after the count has already moved, and are the only ones hooked. +assert(handlers["window.close"] == nil, "window.close counts the window on its way out") +assert(handlers["window.move_to_workspace"] == nil, "window.move_to_workspace does too") LUA -pass "the console is a half-height 2:1 panel by default" +pass "the console is a centered panel until a second app joins it" From 0a65b45ab145c5bc134b6846e72e12dbf459de53 Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Tue, 25 Aug 2026 08:48:49 +0300 Subject: [PATCH 04/48] Refuse hook and state names that are paths omarchy-hook and omarchy-state set join a name straight into a path. A name with a slash, or a bare . or .., points outside the hooks or state directory. Every caller in the repo passes a fixed label, so this is a footgun guard for future callers, not a fix for anything that ships today. Names with dots in the middle (a..b) stay allowed. omarchy-state clear is untouched: find -name matches basenames only. --- bin/omarchy-hook | 12 +++ bin/omarchy-state | 15 ++- test/shell.d/hook-state-name-guard-test.sh | 109 +++++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 test/shell.d/hook-state-name-guard-test.sh diff --git a/bin/omarchy-hook b/bin/omarchy-hook index 8c2a59d9..499cdf14 100755 --- a/bin/omarchy-hook +++ b/bin/omarchy-hook @@ -11,6 +11,18 @@ if (( $# < 1 )); then fi HOOK=$1 + +# Hook names are fixed labels chosen by Omarchy code (post-update, theme-set, +# font-set). The name becomes a filename under the hooks directory. A slash +# would turn it into directory levels, and a bare `.` or `..` would point bash +# at the directory itself or its parent. Refuse those rather than follow them. +# Dots inside a name (a..b) are fine; once slashes are out, only the whole +# name being `.` or `..` can leave the directory. +if [[ -z $HOOK || $HOOK == */* || $HOOK == "." || $HOOK == ".." ]]; then + echo "Invalid hook name: $HOOK" >&2 + exit 2 +fi + HOOK_PATH="$HOME/.config/omarchy/hooks/$1" HOOK_DIR="$HOOK_PATH.d" shift diff --git a/bin/omarchy-state b/bin/omarchy-state index 4fda5b3a..3b2f5e17 100755 --- a/bin/omarchy-state +++ b/bin/omarchy-state @@ -21,6 +21,19 @@ if [[ -z $STATE_NAME ]]; then fi case "$COMMAND" in -set) touch "$STATE_DIR/$STATE_NAME" ;; +set) + # State names are fixed labels (reboot-required, restart-*-required). The + # name becomes a filename under the state directory. A slash would turn it + # into directory levels, and a bare `.` or `..` would touch the directory + # itself or its parent. Refuse those. Dots inside a name (a..b) are fine; + # once slashes are out, only the whole name being `.` or `..` can leave the + # directory. clear needs no such guard: find -name matches basenames only, + # so a pattern can never walk out of the directory. + if [[ $STATE_NAME == */* || $STATE_NAME == "." || $STATE_NAME == ".." ]]; then + echo "Invalid state name: $STATE_NAME" >&2 + exit 2 + fi + touch "$STATE_DIR/$STATE_NAME" + ;; clear) find "$STATE_DIR" -maxdepth 1 -type f -name "$STATE_NAME" -delete ;; esac diff --git a/test/shell.d/hook-state-name-guard-test.sh b/test/shell.d/hook-state-name-guard-test.sh new file mode 100644 index 00000000..2955ad27 --- /dev/null +++ b/test/shell.d/hook-state-name-guard-test.sh @@ -0,0 +1,109 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +work_dir=$(mktemp -d) +trap 'rm -rf "$work_dir"' EXIT + +fake_home="$work_dir/home" +mkdir -p "$fake_home/.config/omarchy/hooks" "$fake_home/.local/state/omarchy" + +# --- omarchy-hook -------------------------------------------------------------- + +# A hook name is a label, not a path. One carrying a slash, or one that is a +# bare `.` or `..`, would run a script from outside the hooks directory. + +cat >"$fake_home/.config/omarchy/hooks/test-hook" <<'SH' +touch "$HOME/hook-ran" +SH + +HOME="$fake_home" "$ROOT/bin/omarchy-hook" test-hook +[[ -f $fake_home/hook-ran ]] || + fail "omarchy hook runs a named hook from the hooks directory" +pass "omarchy hook runs a named hook from the hooks directory" + +# Dots inside a name are not a path. a..b stays inside the hooks directory. +cat >"$fake_home/.config/omarchy/hooks/a..b" <<'SH' +touch "$HOME/dotted-hook-ran" +SH + +HOME="$fake_home" "$ROOT/bin/omarchy-hook" a..b +[[ -f $fake_home/dotted-hook-ran ]] || + fail "omarchy hook accepts a hook name with dots in the middle" +pass "omarchy hook accepts a hook name with dots in the middle" + +for name in . ..; do + status=0 + HOME="$fake_home" "$ROOT/bin/omarchy-hook" "$name" >/dev/null 2>&1 || status=$? + (( status == 2 )) || + fail "omarchy hook refuses a hook name of $name" "exit: $status" + pass "omarchy hook refuses a hook name of $name" +done + +# This file sits where a name of ../../evil would resolve: hooks/../.. is +# ~/.config. +cat >"$fake_home/.config/evil" <<'SH' +touch "$HOME/escape-ran" +SH +chmod +x "$fake_home/.config/evil" + +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-hook" "../../evil" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy hook refuses a hook name with a dot-dot" "exit: $status" +[[ ! -e $fake_home/escape-ran ]] || + fail "omarchy hook runs nothing when it refuses the name" +pass "omarchy hook refuses a hook name with a dot-dot" + +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-hook" "sub/dir" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy hook refuses a hook name with a slash" "exit: $status" +pass "omarchy hook refuses a hook name with a slash" + +# --- omarchy-state ------------------------------------------------------------- + +state_dir="$fake_home/.local/state/omarchy" + +HOME="$fake_home" "$ROOT/bin/omarchy-state" set reboot-required +[[ -f $state_dir/reboot-required ]] || + fail "omarchy state set still creates a plain state file" +pass "omarchy state set still creates a plain state file" + +HOME="$fake_home" "$ROOT/bin/omarchy-state" set v1..2 +[[ -f $state_dir/v1..2 ]] || + fail "omarchy state set accepts a state name with dots in the middle" +pass "omarchy state set accepts a state name with dots in the middle" + +for name in . ..; do + status=0 + HOME="$fake_home" "$ROOT/bin/omarchy-state" set "$name" >/dev/null 2>&1 || status=$? + (( status == 2 )) || + fail "omarchy state set refuses a state name of $name" "exit: $status" + pass "omarchy state set refuses a state name of $name" +done + +# state/../.. is ~/.local. The guard must fire before touch gets there. +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-state" set "../../escape" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy state set refuses a state name with a dot-dot" "exit: $status" +[[ ! -e $fake_home/.local/escape ]] || + fail "omarchy state set creates nothing outside the state directory" +pass "omarchy state set refuses a state name with a dot-dot" + +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-state" set "sub/dir" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy state set refuses a state name with a slash" "exit: $status" +pass "omarchy state set refuses a state name with a slash" + +# clear takes patterns by design ("state-name-or-pattern") and matches +# basenames through find -name, so it can never walk out of the directory. +touch "$state_dir/restart-a-required" "$state_dir/restart-b-required" "$state_dir/keep-me" +HOME="$fake_home" "$ROOT/bin/omarchy-state" clear "restart-*-required" +[[ ! -e $state_dir/restart-a-required && ! -e $state_dir/restart-b-required && -f $state_dir/keep-me ]] || + fail "omarchy state clear still clears matching patterns only" +pass "omarchy state clear still clears matching patterns only" From a75924aca4ad20275557332956fd2d8665160eab Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Sat, 5 Sep 2026 17:57:11 -0400 Subject: [PATCH 05/48] Erase old password hashes during factory reset --- bin/omarchy-system-factory-reset | 39 ++++-- test/shell.d/factory-reset-accounts-test.sh | 146 ++++++++++++++++++++ 2 files changed, 171 insertions(+), 14 deletions(-) create mode 100644 test/shell.d/factory-reset-accounts-test.sh diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index 51eca401..f9e57d95 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -297,19 +297,34 @@ rebuild_next_boot() { umount "$next$esp_mount" } +# Both the staged system and the retained baseline must lose the old hashes. +scrub_factory_accounts() { + local root="$1" user users + + users=$(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$root/etc/passwd") || return 1 + for user in $users; do + userdel --root "$root" "$user" 2>>"$LOG_FILE" || return 1 + rm -rf "${root:?}/home/$user" || return 1 + done + + # passwd --lock preserves the hash. Replace it, then remove the backups + # that userdel and usermod leave behind. + usermod --root "$root" --password '!' root >>"$LOG_FILE" 2>&1 || return 1 + rm -f "$root/etc/"{shadow-,gshadow-,passwd-,group-} +} + # Remove the seller's account material and machine identity from the retained # @factory baseline so it can neither be mounted for recovery nor restore the # seller's account on a future reset. Idempotent (a scrubbed baseline has no # uid>=1000 accounts left to remove). sanitize_factory_baseline() { - local factory="$1" user + local factory="$1" btrfs property set -ts "$factory" ro false - for user in $(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$factory/etc/passwd"); do - userdel --root "$factory" "$user" 2>>"$LOG_FILE" || true - rm -rf "${factory:?}/home/$user" - done - passwd --root "$factory" --lock root >>"$LOG_FILE" 2>&1 || true + if ! scrub_factory_accounts "$factory"; then + btrfs property set -ts "$factory" ro true + fail "could not remove account credentials from the factory baseline (see $LOG_FILE)" + fi rm -f "$factory"/etc/ssh/ssh_host_* rm -f "$factory"/etc/NetworkManager/system-connections/* rm -rf "$factory"/var/lib/NetworkManager/* "$factory/var/lib/tailscale" "$factory/var/lib/iwd" @@ -337,17 +352,13 @@ stage_full_reset() { rm -rf "$next"/var/lib/NetworkManager/* "$next/var/lib/tailscale" "$next/var/lib/iwd" rm -f "$next/var/lib/sddm/state.conf" "$next/etc/sddm.conf.d/autologin.conf" - # A factory snapshot from a normal (normal) install contains the original + # A factory snapshot from a normal install contains the original # user account; first-boot setup must start from none. A leftover account # would keep its password hash and group memberships (including wheel), so # failure here has to abort the reset, not be shrugged off. - local user - for user in $(awk -F: '$3 >= 1000 && $3 < 60000 { print $1 }' "$next/etc/passwd"); do - log "Removing user $user from the factory system" - userdel --root "$next" "$user" 2>>"$LOG_FILE" || - fail "could not remove user $user from the factory system (see $LOG_FILE)" - done - passwd --root "$next" --lock root >>"$LOG_FILE" 2>&1 || true + log "Removing account credentials from the factory system" + scrub_factory_accounts "$next" || + fail "could not remove account credentials from the factory system (see $LOG_FILE)" # @factory itself survives the wipe as the baseline for future resets. If it # came from a normal install it still holds the seller's account and diff --git a/test/shell.d/factory-reset-accounts-test.sh b/test/shell.d/factory-reset-accounts-test.sh new file mode 100644 index 00000000..7f6f4a31 --- /dev/null +++ b/test/shell.d/factory-reset-accounts-test.sh @@ -0,0 +1,146 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +if (( EUID != 0 )); then + if unshare --user --map-root-user true 2>/dev/null; then + exec unshare --user --map-root-user bash "$0" + fi + pass "no unprivileged user namespace; skipping factory account cleanup" + exit 0 +fi + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +# Load the production functions without self-elevation or the reset entrypoint. +awk ' + /^[a-z_]+\(\) \{/ { copying = 1 } + copying { print } + /^}/ { copying = 0 } +' "$ROOT/bin/omarchy-system-factory-reset" >"$test_tmp/functions" + +cat >"$test_tmp/reset" <<'SH' +#!/bin/bash +set -euo pipefail +source "$1/functions" +TOP_MNT="$2" +NEXT_NAME=@omarchy-reset-next +PROVISIONING_DIR=/var/lib/omarchy/provisioning +LOG_FILE="$TOP_MNT/reset.log" + +log() { printf '%s\n' "$1" >>"$LOG_FILE"; } +fail() { log "$1"; exit 1; } + +# Account tools are real. Only snapshots, boot rebuilding, and system services +# are replaced: all writes stay inside this test's disposable directory. +btrfs() { + if [[ $1 == "subvolume" && $2 == "snapshot" ]]; then + mkdir -p "$4" + cp -a "$3/." "$4/" + elif [[ $1 == "property" ]]; then + printf '%s\n' "$6" >"$4/read-only" + else + return 1 + fi +} +systemd-id128() { printf '%032d\n' 1; } +install_provisioning_units() { :; } +encrypted_install() { return 1; } +rebuild_next_boot() { touch "$TOP_MNT/rebuilt"; } +sync() { :; } + +userdel() { + [[ ${FAIL_COMMAND:-} == "userdel" && $2 == "$FAIL_ROOT" ]] && return 42 + command userdel "$@" +} +usermod() { + [[ ${FAIL_COMMAND:-} == "usermod" && $2 == "$FAIL_ROOT" ]] && return 42 + command usermod "$@" +} +rm() { + [[ ${FAIL_COMMAND:-} == "rm" && $* == *"$FAIL_ROOT/etc/shadow-"* ]] && return 42 + command rm "$@" +} + +stage_full_reset +SH + +make_fixture() { + local top="$1" root_hash="${2:-original-root-hash}" + local factory="$top/@factory" + mkdir -p "$top/@" "$factory/etc" "$factory/home/seller" \ + "$factory/usr/bin" "$factory/usr/share/omarchy/install/provisioning" \ + "$factory/var/lib/omarchy/provisioning/packages" + touch "$top/@/old-system" "$factory/home/seller/private-file" \ + "$factory/usr/share/omarchy/install/provisioning/omarchy-provision-owner.service" \ + "$factory/var/lib/omarchy/provisioning/packages/node-v0.tar.gz" + printf '#!/bin/bash\n' >"$factory/usr/bin/omarchy-provision-owner" + chmod +x "$factory/usr/bin/omarchy-provision-owner" + printf 'true\n' >"$factory/read-only" + cat >"$factory/etc/passwd" <<'EOF' +root:x:0:0:root:/root:/bin/bash +daemon:x:1:1:daemon:/:/usr/bin/nologin +seller:x:1000:1000:Seller:/home/seller:/bin/bash +EOF + printf 'root:%s:20000:0:99999:7:::\ndaemon:*:20000:0:99999:7:::\nseller:original-user-hash:20000:0:99999:7:::\n' \ + "$root_hash" >"$factory/etc/shadow" + printf 'root:x:0:\ndaemon:x:1:\nseller:x:1000:\nwheel:x:998:seller\n' >"$factory/etc/group" + printf 'root:!::\ndaemon:!::\nseller:!::\nwheel:!::seller\n' >"$factory/etc/gshadow" + printf 'USERGROUPS_ENAB yes\n' >"$factory/etc/login.defs" + chmod 600 "$factory/etc/"{shadow,gshadow} + for file in passwd shadow group gshadow; do + cp "$factory/etc/$file" "$factory/etc/$file-" + done +} + +assert_scrubbed() { + local root="$1" file + [[ $(awk -F: '$1 == "root" { print $2 }' "$root/etc/shadow") == "!" ]] || + fail "reset erases the root hash while keeping the account locked" + ! grep -q 'original-.*-hash\|seller' "$root/etc/"{passwd,shadow,group,gshadow} || + fail "reset removes seller account credentials and group membership" + [[ ! -e $root/home/seller ]] || fail "reset removes the seller's baseline home" + grep -q '^daemon:\*:' "$root/etc/shadow" || fail "reset preserves service accounts" + [[ $(stat -c '%a' "$root/etc/shadow") == "600" ]] || fail "shadow stays private" + for file in passwd shadow group gshadow; do + [[ ! -e $root/etc/$file- ]] || fail "reset removes the $file backup" + done +} + +for scenario in normal locked; do + top="$test_tmp/$scenario" + if [[ $scenario == "locked" ]]; then + make_fixture "$top" '!' + else + make_fixture "$top" + fi + bash "$test_tmp/reset" "$test_tmp" "$top" || fail "$scenario reset stages successfully" + assert_scrubbed "$top/@factory" + assert_scrubbed "$top/@" + [[ $(cat "$top/@factory/read-only") == "true" ]] || fail "baseline returns to read-only" + [[ -f $top/@/var/lib/omarchy/provisioning/pending && -f $top/rebuilt ]] || + fail "reset reaches provisioning after cleanup" + + bash "$test_tmp/reset" "$test_tmp" "$top" || fail "$scenario reset can be repeated" + assert_scrubbed "$top/@factory" + assert_scrubbed "$top/@" + pass "$scenario reset scrubs both roots, preserves service accounts, and can be repeated" +done + +for target in @omarchy-reset-next @factory; do + for command in userdel usermod rm; do + top="$test_tmp/fail-$target-$command" + make_fixture "$top" + if FAIL_COMMAND="$command" FAIL_ROOT="$top/$target" bash "$test_tmp/reset" "$test_tmp" "$top"; then + fail "reset accepted failed $command in $target" + fi + [[ -f $top/@/old-system && ! -e $top/rebuilt ]] || + fail "failed cleanup must not activate or rebuild the reset system" + [[ $(cat "$top/@factory/read-only") == "true" ]] || + fail "failed cleanup must leave the baseline read-only" + pass "failed $command in $target aborts reset before activation" + done +done From a00be8fa16c941749886a80d91413e9bbe3c09e4 Mon Sep 17 00:00:00 2001 From: omarchybot Date: Sun, 6 Sep 2026 05:37:10 -0700 Subject: [PATCH 06/48] Remove the subuid and subgid backups during the factory scrub userdel rewrites /etc/subuid and /etc/subgid, and like every shadow-utils database write it leaves the previous contents behind in a dash-suffixed backup. The scrub removed four of the six backups those tools produce, so the retained @factory baseline still named the previous owner in /etc/subuid- and /etc/subgid- along with their subordinate ID range. Co-Authored-By: Claude Opus 5 (1M context) --- bin/omarchy-system-factory-reset | 2 +- test/shell.d/factory-reset-accounts-test.sh | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/bin/omarchy-system-factory-reset b/bin/omarchy-system-factory-reset index f9e57d95..43348e5a 100755 --- a/bin/omarchy-system-factory-reset +++ b/bin/omarchy-system-factory-reset @@ -310,7 +310,7 @@ scrub_factory_accounts() { # passwd --lock preserves the hash. Replace it, then remove the backups # that userdel and usermod leave behind. usermod --root "$root" --password '!' root >>"$LOG_FILE" 2>&1 || return 1 - rm -f "$root/etc/"{shadow-,gshadow-,passwd-,group-} + rm -f "$root/etc/"{shadow-,gshadow-,passwd-,group-,subuid-,subgid-} } # Remove the seller's account material and machine identity from the retained diff --git a/test/shell.d/factory-reset-accounts-test.sh b/test/shell.d/factory-reset-accounts-test.sh index 7f6f4a31..ed2bb697 100644 --- a/test/shell.d/factory-reset-accounts-test.sh +++ b/test/shell.d/factory-reset-accounts-test.sh @@ -90,8 +90,10 @@ EOF printf 'root:x:0:\ndaemon:x:1:\nseller:x:1000:\nwheel:x:998:seller\n' >"$factory/etc/group" printf 'root:!::\ndaemon:!::\nseller:!::\nwheel:!::seller\n' >"$factory/etc/gshadow" printf 'USERGROUPS_ENAB yes\n' >"$factory/etc/login.defs" + printf 'seller:100000:65536\n' >"$factory/etc/subuid" + printf 'seller:100000:65536\n' >"$factory/etc/subgid" chmod 600 "$factory/etc/"{shadow,gshadow} - for file in passwd shadow group gshadow; do + for file in passwd shadow group gshadow subuid subgid; do cp "$factory/etc/$file" "$factory/etc/$file-" done } @@ -105,7 +107,7 @@ assert_scrubbed() { [[ ! -e $root/home/seller ]] || fail "reset removes the seller's baseline home" grep -q '^daemon:\*:' "$root/etc/shadow" || fail "reset preserves service accounts" [[ $(stat -c '%a' "$root/etc/shadow") == "600" ]] || fail "shadow stays private" - for file in passwd shadow group gshadow; do + for file in passwd shadow group gshadow subuid subgid; do [[ ! -e $root/etc/$file- ]] || fail "reset removes the $file backup" done } From e522a18ef0d31b5024a5a92697215abe7b57042e Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:59:36 +0300 Subject: [PATCH 07/48] Refuse path-like names in omarchy-hook-install too The runner already rejects a slash, a bare . or .. The installer still joined the type into hooks/.d before mkdir/cp, so a name nothing can run could still land on disk. --- bin/omarchy-hook-install | 11 +++++ test/shell.d/hook-state-name-guard-test.sh | 49 ++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/bin/omarchy-hook-install b/bin/omarchy-hook-install index 7c536482..6e954bd1 100755 --- a/bin/omarchy-hook-install +++ b/bin/omarchy-hook-install @@ -15,6 +15,17 @@ fi HOOK_TYPE=$1 HOOK_FILE=$2 + +# Hook types are the same labels omarchy-hook runs (post-update, theme-set). +# The type becomes a directory under the hooks directory. A slash would turn +# it into directory levels. A bare `.` or `..` is a name the runner already +# refuses, so installing under it would write a hook nothing can run. Refuse +# those rather than mkdir/cp into them. Dots inside a name (a..b) are fine. +if [[ -z $HOOK_TYPE || $HOOK_TYPE == */* || $HOOK_TYPE == "." || $HOOK_TYPE == ".." ]]; then + echo "Invalid hook name: $HOOK_TYPE" >&2 + exit 2 +fi + HOOK_DIR="$HOME/.config/omarchy/hooks/$HOOK_TYPE.d" HOOK_NAME=$(basename "$HOOK_FILE") HOOK_PATH="$HOOK_DIR/$HOOK_NAME" diff --git a/test/shell.d/hook-state-name-guard-test.sh b/test/shell.d/hook-state-name-guard-test.sh index 2955ad27..0157202b 100644 --- a/test/shell.d/hook-state-name-guard-test.sh +++ b/test/shell.d/hook-state-name-guard-test.sh @@ -63,6 +63,55 @@ HOME="$fake_home" "$ROOT/bin/omarchy-hook" "sub/dir" >/dev/null 2>&1 || status=$ fail "omarchy hook refuses a hook name with a slash" "exit: $status" pass "omarchy hook refuses a hook name with a slash" +# --- omarchy-hook-install ------------------------------------------------------ + +# The installer joins the type into ~/.config/omarchy/hooks/.d before +# mkdir/cp. The runner already refuses a slashed type; install must too, or a +# name the runner will not run still lands on disk. + +source_hook="$work_dir/source-hook" +cat >"$source_hook" <<'SH' +#!/bin/bash +true +SH + +HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" post-update "$source_hook" >/dev/null +[[ -f $fake_home/.config/omarchy/hooks/post-update.d/source-hook ]] || + fail "omarchy hook install still installs a named hook" +pass "omarchy hook install still installs a named hook" + +HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" a..b "$source_hook" >/dev/null +[[ -f $fake_home/.config/omarchy/hooks/a..b.d/source-hook ]] || + fail "omarchy hook install accepts a hook name with dots in the middle" +pass "omarchy hook install accepts a hook name with dots in the middle" + +for name in . ..; do + status=0 + HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" "$name" "$source_hook" >/dev/null 2>&1 || status=$? + (( status == 2 )) || + fail "omarchy hook install refuses a hook name of $name" "exit: $status" + [[ ! -e $fake_home/.config/omarchy/hooks/${name}.d ]] || + fail "omarchy hook install creates no directory for a hook name of $name" + pass "omarchy hook install refuses a hook name of $name" +done + +# hooks/../../evil.d is ~/.config/evil.d. The guard must fire before mkdir. +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" "../../evil" "$source_hook" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy hook install refuses a hook name with a dot-dot" "exit: $status" +[[ ! -e $fake_home/.config/evil.d ]] || + fail "omarchy hook install creates nothing outside the hooks directory" +pass "omarchy hook install refuses a hook name with a dot-dot" + +status=0 +HOME="$fake_home" "$ROOT/bin/omarchy-hook-install" "sub/dir" "$source_hook" >/dev/null 2>&1 || status=$? +(( status == 2 )) || + fail "omarchy hook install refuses a hook name with a slash" "exit: $status" +[[ ! -e $fake_home/.config/omarchy/hooks/sub ]] || + fail "omarchy hook install creates no nested directory from a slashed name" +pass "omarchy hook install refuses a hook name with a slash" + # --- omarchy-state ------------------------------------------------------------- state_dir="$fake_home/.local/state/omarchy" From 649c0b74333b39fec6341d4d57279eb87d2110ae Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Mon, 7 Sep 2026 22:56:48 -0400 Subject: [PATCH 08/48] Install broadcom-wl-dkms now that Arch dropped the prebuilt module Arch removed the prebuilt broadcom-wl package on 2026-09-02 and rebuilt broadcom-wl-dkms with replaces=(broadcom-wl). A replaces entry only helps upgrades of an already-installed package, so the name is now unresolvable as an explicit pacman target and the ISO offline mirror download fails with "target not found: broadcom-wl". broadcom-wl-dkms depends on dkms, so the explicit dkms in the hardware fix is redundant; linux-headers stays because the module has to build against the running kernel. --- install/hardware/fix-bcm43xx.sh | 2 +- install/omarchy-other.packages | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/install/hardware/fix-bcm43xx.sh b/install/hardware/fix-bcm43xx.sh index a788d6f9..6b7e5648 100644 --- a/install/hardware/fix-bcm43xx.sh +++ b/install/hardware/fix-bcm43xx.sh @@ -6,5 +6,5 @@ pci_info=$(lspci -nn) if (echo "$pci_info" | grep -q "14e4:43a0" || echo "$pci_info" | grep -q "14e4:4331"); then echo "BCM4360 / BCM4331 detected" - omarchy-pkg-add broadcom-wl dkms linux-headers + omarchy-pkg-add broadcom-wl-dkms linux-headers fi diff --git a/install/omarchy-other.packages b/install/omarchy-other.packages index e5d56d56..3214657a 100644 --- a/install/omarchy-other.packages +++ b/install/omarchy-other.packages @@ -5,7 +5,7 @@ autoconf-archive asusctl base base-devel -broadcom-wl +broadcom-wl-dkms btrfs-progs dkms egl-wayland From 138d839b37033aca3ccd93803417f2470f839441 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Tue, 8 Sep 2026 02:22:55 -0400 Subject: [PATCH 09/48] Keep Cursor bundled Node off the mise PATH --- etc/mise/conf.d/omarchy.toml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 etc/mise/conf.d/omarchy.toml diff --git a/etc/mise/conf.d/omarchy.toml b/etc/mise/conf.d/omarchy.toml new file mode 100644 index 00000000..c9354a2e --- /dev/null +++ b/etc/mise/conf.d/omarchy.toml @@ -0,0 +1,4 @@ +# Keep Cursor optional while applying these options whenever mise installs it. +# Expose only its launcher so the bundled Node cannot shadow the user's Node. +[tool_alias] +cursor-agent = 'http:cursor-agent[bin_path=bin,postinstall=mkdir -p "$MISE_TOOL_INSTALL_PATH/bin" && ln -sfn ../dist-package/cursor-agent "$MISE_TOOL_INSTALL_PATH/bin/cursor-agent"]' From f5cd244ed07892e94998a6da81f1cfff19e0677e Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Tue, 8 Sep 2026 02:36:58 -0400 Subject: [PATCH 10/48] Retire the stock user icon font missed by Quattro upgrades --- agents/skills/icon-font.md | 10 +- bin/omarchy-upgrade-to-quattro | 4 +- migrations/1788848726.sh | 21 ++++ .../fixtures/legacy-icon-font/README.md | 3 + .../fixtures/legacy-icon-font/omarchy.ttf | Bin 0 -> 1824 bytes .../legacy-icon-font-migration-test.sh | 106 ++++++++++++++++++ 6 files changed, 135 insertions(+), 9 deletions(-) create mode 100644 migrations/1788848726.sh create mode 100644 test/shell.d/fixtures/legacy-icon-font/README.md create mode 100644 test/shell.d/fixtures/legacy-icon-font/omarchy.ttf create mode 100644 test/shell.d/legacy-icon-font-migration-test.sh diff --git a/agents/skills/icon-font.md b/agents/skills/icon-font.md index 333f8752..15585006 100644 --- a/agents/skills/icon-font.md +++ b/agents/skills/icon-font.md @@ -70,10 +70,6 @@ magick -background white -fill black -font default/fonts/omarchy/omarchy.ttf \ -pointsize 110 label:@/tmp/row.txt /tmp/font-row.png ``` -Then confirm it in the running menu per -[`visual-verification.md`](visual-verification.md). Fontconfig prefers the -packaged font over a copy in `~/.local/share/fonts` for the same family, so a -preview needs either the real file replaced or a `` rule in -`~/.config/fontconfig/conf.d/` pointing fontconfig away from the packaged one. -Restart the shell afterwards — Qt reads the font database at startup, so -`omarchy menu refresh` alone will not pick up a changed font. +Then confirm it in the running menu per [`visual-verification.md`](visual-verification.md). Avoid leaving two fonts with the `omarchy` family registered: Qt can use an old copy in `~/.local/share/fonts` even when `fc-match omarchy` reports the packaged font. For a preview, either replace the packaged file in the disposable VM or temporarily exclude it with a `` rule in `~/.config/fontconfig/conf.d/` before loading the candidate. + +Refresh the font cache and restart the shell afterwards — Qt reads the font database at startup, so `omarchy menu refresh` alone will not pick up a changed font. Remove temporary fonts and rules after verification. diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 21691914..e6a27ee6 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1569,7 +1569,6 @@ refresh xournalpp/settings.xml 1c1a9efbf1b6dc7813bf3440fd5bf8409fc283e8d0192ca29 # fastfetch/config.jsonc -> /etc/fastfetch/config.jsonc # fontconfig/fonts.conf -> /usr/share/fontconfig/conf.avail/50-omarchy.conf # mimeapps.list -> /usr/share/applications/mimeapps.list -# omarchy.ttf -> /usr/share/fonts/omarchy/omarchy.ttf # systemd/user/bt-agent.service -> /usr/lib/systemd/user/bt-agent.service # systemd/user/omarchy-recover-internal-monitor.service -> /usr/lib/systemd/user/omarchy-recover-internal-monitor.service # systemd/user/omarchy-sleep-lock.service -> /usr/lib/systemd/user/omarchy-sleep-lock.service @@ -1602,7 +1601,6 @@ retire fontconfig/fonts.conf 6dec98b539388b95ecfdb3c7ab951001c712820eb0581002832 retire fontconfig/fonts.conf 3545f6c5a8c1465df7a4e251b3c2047d4ba03654c86636794f64cb0d8ea8ef63 retire fontconfig/fonts.conf 0f085b449f1cbe8eda3b59a6235bf2a3ddb6e982ab5d22fa642248916e63bafc retire mimeapps.list 3b574cef135b5deb7a8a0c7e17139037cf1fe155300e3809f60bed0e04120975 -retire omarchy.ttf e55e67119e82f56f92d90cbf54b7ccc1b2946b32c535a29370439d7ef5215966 retire systemd/user/bt-agent.service 0406b577a1225dc2a9f86638d3c346eb3635168576f04050be50ebcc0be6be12 retire systemd/user/omarchy-recover-internal-monitor.service b9b92cedc44cf3cb6216948629be55b53d16746e31896dc6469fd49ba55e82f4 retire systemd/user/omarchy-recover-internal-monitor.service e1483079b9f2aefcd43b4722c75a31643e5f3bb5a2eada5f52f1d7d201e8c289 @@ -2381,6 +2379,8 @@ cleanup_retired_services ensure_sleep_lock_service remove_retired_default_packages run_final_system_package_upgrade +# This also retires the stock ~/.local/share/fonts/omarchy.ttf missed by the +# former ~/.config retirement entry, through migration 1788848726. run_post_upgrade_migrations run_post_upgrade_update_steps refresh_current_theme_after_upgrade diff --git a/migrations/1788848726.sh b/migrations/1788848726.sh new file mode 100644 index 00000000..ab33ffcc --- /dev/null +++ b/migrations/1788848726.sh @@ -0,0 +1,21 @@ +echo "Retire the stock user icon font missed by the Quattro upgrade" + +legacy_font="$HOME/.local/share/fonts/omarchy.ttf" + +# The upgrader treated this as ~/.config/omarchy.ttf and left the old family +# registered alongside the packaged font. Preserve custom fonts and symlinks. +if [[ -f $legacy_font && ! -L $legacy_font ]]; then + legacy_hash=$(sha256sum "$legacy_font") + if [[ ${legacy_hash%% *} == "e55e67119e82f56f92d90cbf54b7ccc1b2946b32c535a29370439d7ef5215966" ]]; then + if [[ ! -f /usr/share/fonts/omarchy/omarchy.ttf ]]; then + echo "Packaged Omarchy icon font is missing; keeping the legacy font." >&2 + exit 1 + fi + + rm "$legacy_font" + fi +fi + +# Also refresh on retries after removal succeeded but the cache refresh failed. +# The normal update restarts the shell, which reloads Qt's font database. +fc-cache -f diff --git a/test/shell.d/fixtures/legacy-icon-font/README.md b/test/shell.d/fixtures/legacy-icon-font/README.md new file mode 100644 index 00000000..c773098d --- /dev/null +++ b/test/shell.d/fixtures/legacy-icon-font/README.md @@ -0,0 +1,3 @@ +Stock Omarchy icon font from `config/omarchy.ttf` at `babfafa5^`, before fonts moved into the settings package. SHA-256: `e55e67119e82f56f92d90cbf54b7ccc1b2946b32c535a29370439d7ef5215966`. + +The migration test uses the real font so it exercises the exact-content guard without mocking `sha256sum`. diff --git a/test/shell.d/fixtures/legacy-icon-font/omarchy.ttf b/test/shell.d/fixtures/legacy-icon-font/omarchy.ttf new file mode 100644 index 0000000000000000000000000000000000000000..273256d07549caa5de9121fc13110e9b704b63d5 GIT binary patch literal 1824 zcmcIkPiz!b82^204Gd`!6Ps-*6bvyQ2u?A9)qpfHY1CmlyIV4|vzbl_ zYdnx}(a;1BgacfA(~~3~3`7rxKw^US;=zL+G}?H^cxV@X-|VZj&&o_tfb4)X(osQeLF|u^)NuuP<#`0Ib)@TcFkpZX|&So&t+ zqvzkek4n$?k9&9L)p2dMdn=V-nQ)Ek!!Wozl4{EwzylRYYA>_aw_qK0kH3%p&`Vo* z$rRMH_fqw+gtH9~B@-$1Q>#!Tc*V>%=OS;yX4V3c+Jw1nB=Ut;VtsD?$f(P`S^mMe za77FG9JQO!d-bC8!Me?&!vYo$I?KY{clG|vc`?sAQ_DP5sf(FkXr z5cM{ilwZUaYAks0DMyHrptFnzdV-tjs9s0H_>Due>Z$0O`Rr^Ug>@0n3TjqTc1)Vv zv_y87&noBBtovN%+%_TFVV+^VRNt`fL2pG_w-OoF8;uQXmnVkxwmXKo<}#tOxpT1Z zH;(L`WKCVc4!n$A7y=IM9@zZ?#{^l(nKU%9U-l0SZ6)jM%5ST`+UO>7Y-J%=T^*F` z-7l@_+Lf+ft68;Lckj}Y>)Kd>n@d-gtiRpWMQhq!9i-@XZ`8CXcG_)InZBU4DlOuo z(dUTz4Yr8~49@fKr|(ZmyKAq(WxS56!4+)92ioPf{!jW58f+1NVsMW5GlOm7s|M$> z9g7ARsQ=dBGNy3H;0j)qopIzPezS8jXv~LRVk9$71FH~Oj{>syT2B}w)&J2;5|4a{?2 zs@rJWC{58b!5n7k)q;b4JfpG4A|~n6%KSW}oM-5FhP~u4?i}-sQ=6hrxo)yKaI^K) zJLHIIYiG8z)cEwSvTrr2jl&q_s6XakrI%LP`%~*(>c4t3>Jv+HVoP2MQk0VPNf||R Qs1i#go+RQ+Q?&Vkha6VlMF0Q* literal 0 HcmV?d00001 diff --git a/test/shell.d/legacy-icon-font-migration-test.sh b/test/shell.d/legacy-icon-font-migration-test.sh new file mode 100644 index 00000000..c042f76c --- /dev/null +++ b/test/shell.d/legacy-icon-font-migration-test.sh @@ -0,0 +1,106 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +migration="$ROOT/migrations/1788848726.sh" +fixture="$ROOT/test/shell.d/fixtures/legacy-icon-font/omarchy.ttf" +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +export FONT_TEST_HOME="$test_dir/home with spaces" +export FONT_TEST_PACKAGE="$test_dir/packaged-font.ttf" +export FONT_TEST_CACHE_LOG="$test_dir/cache.log" +legacy_font="$FONT_TEST_HOME/.local/share/fonts/omarchy.ttf" + +# Redirect only the filesystem roots; run the real hash check and removal. +# Never change the developer's HOME or refresh their real font cache. +python3 - "$migration" "$test_dir/migration.sh" <<'PY' +import pathlib +import sys + +source = pathlib.Path(sys.argv[1]).read_text() +source = source.replace('$HOME', '$FONT_TEST_HOME') +source = source.replace('/usr/share/fonts/omarchy/omarchy.ttf', '$FONT_TEST_PACKAGE') +pathlib.Path(sys.argv[2]).write_text(source) +PY + +mkdir -p "$test_dir/bin" +cat > "$test_dir/bin/fc-cache" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >> "$FONT_TEST_CACHE_LOG" +exit "${FONT_TEST_CACHE_STATUS:-0}" +SH +chmod +x "$test_dir/bin/fc-cache" + +reset_fonts() { + rm -rf "$FONT_TEST_HOME" + mkdir -p "$FONT_TEST_HOME/.local/share/fonts" "$FONT_TEST_HOME/.config" + cp "$ROOT/default/fonts/omarchy/omarchy.ttf" "$FONT_TEST_PACKAGE" + : > "$FONT_TEST_CACHE_LOG" +} + +run_migration() { + PATH="$test_dir/bin:$PATH" bash -euo pipefail "$test_dir/migration.sh" > "$test_dir/output" 2>&1 +} + +reset_fonts +cp "$fixture" "$legacy_font" +cp "$fixture" "$FONT_TEST_HOME/.config/omarchy.ttf" +run_migration +[[ ! -e $legacy_font ]] || fail "stock font is removed from the actual user font directory" +cmp "$fixture" "$FONT_TEST_HOME/.config/omarchy.ttf" || fail "unrelated config path is untouched" +cmp "$ROOT/default/fonts/omarchy/omarchy.ttf" "$FONT_TEST_PACKAGE" || fail "packaged font is untouched" +[[ $(cat "$FONT_TEST_CACHE_LOG") == "-f" ]] || fail "font cache is refreshed after retirement" +pass "retire the known stock font at its real path and refresh the cache" + +run_migration +[[ ! -e $legacy_font ]] || fail "a second run leaves the stock font retired" +pass "font retirement is idempotent" + +reset_fonts +cp "$fixture" "$legacy_font" +printf 'custom modification\n' >> "$legacy_font" +cp "$legacy_font" "$test_dir/custom-font.ttf" +run_migration +cmp "$test_dir/custom-font.ttf" "$legacy_font" || fail "custom font is preserved" +pass "preserve a modified font with the legacy filename" + +reset_fonts +cp "$fixture" "$test_dir/symlink-target.ttf" +ln -s "$test_dir/symlink-target.ttf" "$legacy_font" +run_migration +[[ -L $legacy_font ]] || fail "user font symlink is preserved" +cmp "$fixture" "$test_dir/symlink-target.ttf" || fail "symlink target is untouched" +pass "preserve user font symlinks even when they point to the stock font" + +reset_fonts +run_migration +[[ ! -e $legacy_font ]] || fail "an absent user font is left absent" +pass "handle installs without a legacy user font" + +reset_fonts +cp "$fixture" "$legacy_font" +rm "$FONT_TEST_PACKAGE" +if run_migration; then + fail "missing packaged font keeps the repair pending" +fi +cmp "$fixture" "$legacy_font" || fail "keep the stock font until its replacement is present" +[[ ! -s $FONT_TEST_CACHE_LOG ]] || fail "missing replacement stops before cache refresh" +pass "preserve the stock font and fail when the packaged replacement is missing" + +reset_fonts +cp "$fixture" "$legacy_font" +if FONT_TEST_CACHE_STATUS=17 run_migration; then + fail "font cache failure keeps the repair pending" +fi +[[ ! -e $legacy_font ]] || fail "cache failure follows successful retirement" +run_migration +(( $(wc -l < "$FONT_TEST_CACHE_LOG") == 2 )) || fail "retry refreshes the cache after the file was removed" +pass "retry a failed cache refresh after successful font retirement" + +if grep -q $'^retire\tomarchy.ttf\t' "$ROOT/bin/omarchy-upgrade-to-quattro"; then + fail "upgrader no longer treats the user font as a config file" +fi +pass "upgrader leaves font retirement to its post-upgrade migrations" From 7e8feb047d8e1989ba1ae1fe5d8faa38f3f5fe60 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 8 Sep 2026 14:09:51 +0200 Subject: [PATCH 11/48] Relay the Elgato Cam Link 4K as a 16:9 virtual camera (#10809) * Relay the Elgato Cam Link 4K as a 16:9 virtual camera Browser meeting apps such as Zoom's web client ask the Cam Link for a standard-definition stream, and Chromium settles on the smallest mode it offers, 640x480. The Cam Link fills that 4:3 frame by cropping its 16:9 input, and the app then paints the frame into a 16:9 tile, so everyone comes out stretched wide. The web client has no HD switch to avoid it. Hide the raw capture node from users and re-expose it through v4l2-relayd as a 1280x720 virtual camera with the same name, so there is still just one "Cam Link 4K" to pick and no way to negotiate 4:3 from it. udev starts the relay whenever the Cam Link enumerates and stops it on unplug, and the relay only pulls frames while something is watching. The sink runs unsynced because v4l2src stamps each frame with its capture time, which a synced sink treats as already late and drops. Co-Authored-By: Claude Fable 5.1 * Take the review fixes for the Cam Link 4K relay Tie only the device's stop into the relay instance. A start dependency on it left a job waiting on a device that never comes whenever the base v4l2-relayd.service is started without a Cam Link attached, since the package generator wants every configured instance. Let the loopback unit rerun on each relay start, so a deleted or unloaded device is recreated on replug instead of the oneshot staying satisfied. Start the relay outright at the end of the migration. The udev trigger only starts it when the rule is new to the device, and a failed module build would otherwise pass silently with the raw camera already hidden. Run the hardware fix after the Panther Lake kernel swap, as it pulls in a DKMS module that would otherwise build twice. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- bin/omarchy-hw-elgato-camlink-4k | 5 +++++ .../system/camlink-4k-loopback.service | 9 +++++++++ .../camlink.conf | 16 +++++++++++++++ default/udev/elgato-camlink-4k.rules | 6 ++++++ default/v4l2-relayd/camlink.conf | 9 +++++++++ install/hardware/all.sh | 3 +++ install/hardware/fix-elgato-camlink-4k.sh | 18 +++++++++++++++++ migrations/1788862626.sh | 20 +++++++++++++++++++ 8 files changed, 86 insertions(+) create mode 100755 bin/omarchy-hw-elgato-camlink-4k create mode 100644 default/systemd/system/camlink-4k-loopback.service create mode 100644 default/systemd/system/v4l2-relayd@camlink.service.d/camlink.conf create mode 100644 default/udev/elgato-camlink-4k.rules create mode 100644 default/v4l2-relayd/camlink.conf create mode 100644 install/hardware/fix-elgato-camlink-4k.sh create mode 100644 migrations/1788862626.sh diff --git a/bin/omarchy-hw-elgato-camlink-4k b/bin/omarchy-hw-elgato-camlink-4k new file mode 100755 index 00000000..e94a54b2 --- /dev/null +++ b/bin/omarchy-hw-elgato-camlink-4k @@ -0,0 +1,5 @@ +#!/bin/bash + +# omarchy:summary=Detect whether an Elgato Cam Link 4K is plugged in. + +grep -qsx "Cam Link 4K" /sys/bus/usb/devices/*/product diff --git a/default/systemd/system/camlink-4k-loopback.service b/default/systemd/system/camlink-4k-loopback.service new file mode 100644 index 00000000..3b7d7b07 --- /dev/null +++ b/default/systemd/system/camlink-4k-loopback.service @@ -0,0 +1,9 @@ +[Unit] +Description=Create the Cam Link 4K virtual camera +After=systemd-modules-load.service + +[Service] +# Reruns on every relay start, so a deleted or unloaded device comes back. +Type=oneshot +ExecStart=/usr/bin/modprobe v4l2loopback +ExecStart=/bin/sh -c 'grep -qsx "Cam Link 4K" /sys/devices/virtual/video4linux/*/name || exec v4l2loopback-ctl add -n "Cam Link 4K" -x 1' diff --git a/default/systemd/system/v4l2-relayd@camlink.service.d/camlink.conf b/default/systemd/system/v4l2-relayd@camlink.service.d/camlink.conf new file mode 100644 index 00000000..956b2a41 --- /dev/null +++ b/default/systemd/system/v4l2-relayd@camlink.service.d/camlink.conf @@ -0,0 +1,16 @@ +[Unit] +# udev starts this when the Cam Link 4K appears. Only the device's stop is +# tied in: a start dependency on it would leave a job waiting whenever the +# base v4l2-relayd.service is started with no Cam Link attached. +ConditionPathExists=/dev/camlink4k +StopPropagatedFrom=dev-camlink4k.device +Requires=camlink-4k-loopback.service +After=camlink-4k-loopback.service + +[Service] +RestartSec=2 +# The packaged command line, plus sync=false on the sink. v4l2src stamps each +# frame with its capture time, so by the time it reaches the sink it is already +# past due and a synced sink drops every one of them. +ExecStart= +ExecStart=/bin/sh -c 'DEVICE=$(grep -l -m1 -E "^${CARD_LABEL}$" /sys/devices/virtual/video4linux/*/name | cut -d/ -f6); exec /usr/bin/v4l2-relayd -i "${VIDEOSRC}" $${SPLASHSRC:+-s "$${SPLASHSRC}"} -o "appsrc name=appsrc caps=video/x-raw,format=${FORMAT},width=${WIDTH},height=${HEIGHT},framerate=${FRAMERATE} ! videoconvert ! v4l2sink name=v4l2sink sync=false device=/dev/$${DEVICE}" $EXTRA_OPTS' diff --git a/default/udev/elgato-camlink-4k.rules b/default/udev/elgato-camlink-4k.rules new file mode 100644 index 00000000..27740681 --- /dev/null +++ b/default/udev/elgato-camlink-4k.rules @@ -0,0 +1,6 @@ +# Keep the Elgato Cam Link 4K's raw capture node away from users and hand it to +# the v4l2-relayd instance that re-exposes it as a 16:9 virtual camera. +# Sorted after 70-uaccess adds the tag and before 73-seat-late applies its ACL. +SUBSYSTEM=="video4linux", ENV{ID_VENDOR_ID}=="0fd9", ENV{ID_MODEL}=="Cam_Link_4K", ENV{ID_V4L_CAPABILITIES}==":capture:", \ + TAG-="uaccess", OWNER="root", GROUP="root", MODE="0600", SYMLINK+="camlink4k", \ + TAG+="systemd", ENV{SYSTEMD_ALIAS}="/dev/camlink4k", ENV{SYSTEMD_WANTS}="v4l2-relayd@camlink.service" diff --git a/default/v4l2-relayd/camlink.conf b/default/v4l2-relayd/camlink.conf new file mode 100644 index 00000000..7f4ce67e --- /dev/null +++ b/default/v4l2-relayd/camlink.conf @@ -0,0 +1,9 @@ +# Elgato Cam Link 4K relayed as a fixed 16:9 virtual camera. Browser meeting +# apps send 720p at most; raise WIDTH/HEIGHT (up to 3840x2160) for apps that +# can use more. +VIDEOSRC="v4l2src device=/dev/camlink4k" +FORMAT=NV12 +WIDTH=1280 +HEIGHT=720 +FRAMERATE=30/1 +CARD_LABEL="Cam Link 4K" diff --git a/install/hardware/all.sh b/install/hardware/all.sh index 7b806ce8..defeef1c 100644 --- a/install/hardware/all.sh +++ b/install/hardware/all.sh @@ -24,6 +24,9 @@ run_logged "$OMARCHY_INSTALL/hardware/intel/fred.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/fix-wifi7-eht.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/sof-firmware.sh" +# Pulls in v4l2loopback-dkms, so it sits after the Panther Lake kernel swap. +run_logged "$OMARCHY_INSTALL/hardware/fix-elgato-camlink-4k.sh" + # Rebuilds the boot image, so it has to follow the Panther Lake kernel swap # above rather than sit with the other Dell leaf at the top of this file. run_logged "$OMARCHY_INSTALL/hardware/dell-xps13-sidecar-amps.sh" diff --git a/install/hardware/fix-elgato-camlink-4k.sh b/install/hardware/fix-elgato-camlink-4k.sh new file mode 100644 index 00000000..f726fd9a --- /dev/null +++ b/install/hardware/fix-elgato-camlink-4k.sh @@ -0,0 +1,18 @@ +# Expose the Elgato Cam Link 4K as a 16:9-only virtual camera. +# Browsers ask it for 640x480, which it fills by cropping, and the 4:3 frame +# then gets stretched into a 16:9 tile. The raw node is hidden from users and +# v4l2-relayd re-exposes it at 1280x720 under the same name. + +if omarchy-hw-elgato-camlink-4k; then + mapfile -t kernel_headers < <(pacman -Qqs '^linux(-zen|-lts|-hardened|-t2|-ptl|-omarchy-bore)?$' | sed 's/$/-headers/') + omarchy-pkg-add "${kernel_headers[@]}" v4l2loopback-dkms v4l2loopback-utils v4l2-relayd + + sudo install -Dm644 "$OMARCHY_PATH/default/udev/elgato-camlink-4k.rules" /etc/udev/rules.d/71-elgato-camlink-4k.rules + sudo install -Dm644 "$OMARCHY_PATH/default/v4l2-relayd/camlink.conf" /etc/v4l2-relayd.d/camlink.conf + sudo install -Dm644 "$OMARCHY_PATH/default/systemd/system/camlink-4k-loopback.service" /etc/systemd/system/camlink-4k-loopback.service + sudo install -Dm644 "$OMARCHY_PATH/default/systemd/system/v4l2-relayd@camlink.service.d/camlink.conf" "/etc/systemd/system/v4l2-relayd@camlink.service.d/camlink.conf" + + # The module's own default device would otherwise show up in browsers as + # "Dummy video device" on machines without another relay. + echo "options v4l2loopback exclusive_caps=1" | sudo tee /etc/modprobe.d/v4l2loopback-exclusive-caps.conf >/dev/null +fi diff --git a/migrations/1788862626.sh b/migrations/1788862626.sh new file mode 100644 index 00000000..822d8513 --- /dev/null +++ b/migrations/1788862626.sh @@ -0,0 +1,20 @@ +echo "Expose the Elgato Cam Link 4K as a 16:9 virtual camera" + +if omarchy-hw-elgato-camlink-4k; then + source "$OMARCHY_PATH/install/hardware/fix-elgato-camlink-4k.sh" + + sudo systemctl daemon-reload + sudo udevadm control --reload + + # Re-run the rules for the Cam Link that is plugged in now so it gets hidden + # and relayed right away. The user ACL from its first plug survives a + # re-trigger, so drop it here; a replug or reboot does the same on its own. + sudo udevadm trigger --action=add --subsystem-match=video4linux + sudo udevadm settle + [[ -e /dev/camlink4k ]] && sudo setfacl -b /dev/camlink4k + + # The trigger only starts the relay when the rule is new to the device. + # Start it outright so a failed module build or loopback surfaces here, + # with the raw camera hidden, rather than passing silently. + sudo systemctl start v4l2-relayd@camlink.service +fi From 131bda36b5c551b9a4d560172221f88ec639667c Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Tue, 8 Sep 2026 12:45:31 -0400 Subject: [PATCH 12/48] Credit _SiCk // afflicted.sh for sudo expiry validation Thanks to _SiCk // afflicted.sh for independently reproducing the passwordless sudo persistence issue and reviewing the proposed fix in #7990. The follow-up fix landed in #9387 (945af75a), with the 4.0.3 backport in cf459ba7. The original boot-cleanup patch was contributed by @Adolanium. Preserve that co-author credit alongside this acknowledgment. Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com> From 5b91db503c904bbfc5f34bdaaa9c708814958f3d Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Tue, 8 Sep 2026 12:45:31 -0400 Subject: [PATCH 13/48] Credit @Chainfire for the asdcontrol security report Chainfire (@Chainfire) privately reported the passwordless asdcontrol watchdog reboot on August 25, 2026. The fix landed in the 4.0.2 security work: #9200 (df819a6f) removed Omarchy's unrestricted sudo grant, while omacom/omarchy-pkgs#242 and omacom/omarchy-pkgs#243 restricted the package's grant. This records the reporter credit omitted from those changes. Reported-by: Chainfire (https://github.com/Chainfire) From 27f10ffd97da8c90093fc09667ec5db8641838bc Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Tue, 8 Sep 2026 12:50:04 -0500 Subject: [PATCH 14/48] Fix Hermes desktop installation after module split --- bin/omarchy-install-ai-hermes | 5 ++++- test/shell.d/hermes-desktop-install-test.sh | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/bin/omarchy-install-ai-hermes b/bin/omarchy-install-ai-hermes index 5bf6b674..6b3764fd 100755 --- a/bin/omarchy-install-ai-hermes +++ b/bin/omarchy-install-ai-hermes @@ -140,7 +140,10 @@ import sys from pathlib import Path sys.path.insert(0, sys.argv[1]) -from hermes_cli.main import _write_desktop_build_stamp +if Path(sys.argv[1], "hermes_cli/main_desktop.py").is_file(): + from hermes_cli.main_desktop import _write_desktop_build_stamp +else: + from hermes_cli.main import _write_desktop_build_stamp _write_desktop_build_stamp(Path(sys.argv[1]), source_mode=False) PY diff --git a/test/shell.d/hermes-desktop-install-test.sh b/test/shell.d/hermes-desktop-install-test.sh index d9d342cf..8a9833fe 100644 --- a/test/shell.d/hermes-desktop-install-test.sh +++ b/test/shell.d/hermes-desktop-install-test.sh @@ -357,3 +357,18 @@ OMARCHY_TEST_HOME="$hermes_home/PrOfIlEs/coder/../coder/" run_installer || fail [[ -x $runtime/apps/desktop/release/linux-unpacked/Hermes ]] || fail "profile uses the canonical root runtime" grep -qxF "$hermes_home" "$test_tmp/install-args" || fail "canonical custom home reaches upstream installer" pass "custom profile paths normalize to the shared Hermes home" + +# New releases moved the stamp writer out of main. Keep the earlier cases on +# the old layout and exercise a fresh installation with the relocated helper. +git -C "$test_tmp/seed" mv hermes_cli/main.py hermes_cli/main_desktop.py +printf 'raise AssertionError("legacy module imported after desktop split")\n' >"$test_tmp/seed/hermes_cli/main.py" +git -C "$test_tmp/seed" add hermes_cli/main.py +git -C "$test_tmp/seed" -c user.name=Test -c user.email=test@example.invalid commit -qm split-desktop +release_commit=$(git -C "$test_tmp/seed" rev-parse HEAD) +export OMARCHY_TEST_RELEASE_COMMIT="$release_commit" +printf '{"branch":"main","commit":"%s"}\n' "$release_commit" >"$test_tmp/package/resources/install-stamp.json" +new_home split-desktop +run_installer || fail "setup supports the relocated desktop helper" "$(cat "$test_tmp/output")" +[[ $(cat "$hermes_home/desktop-build-stamp.json") == 'upstream build stamp' ]] || fail "relocated helper writes the build stamp" +grep -qx launch "$test_tmp/events" || fail "setup launches after the relocated helper writes the stamp" +pass "new releases use the relocated desktop stamp writer" From 1cb960ac43142eac98283445e7db706ea654cbf2 Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Tue, 8 Sep 2026 15:02:17 -0500 Subject: [PATCH 15/48] Use the Hermes desktop portrait for menu icons --- default/fonts/omarchy/README.md | 2 +- default/fonts/omarchy/hermes.svg | 27 +++++++++++++++++++++++++++ default/fonts/omarchy/omarchy.ttf | Bin 5648 -> 9440 bytes 3 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 default/fonts/omarchy/hermes.svg diff --git a/default/fonts/omarchy/README.md b/default/fonts/omarchy/README.md index cb633829..b731549d 100644 --- a/default/fonts/omarchy/README.md +++ b/default/fonts/omarchy/README.md @@ -12,7 +12,7 @@ The private-use glyphs in `omarchy.ttf` are: - `U+E907` — Ollama, from - `U+E908` — T3 Code, traced from the app icon in , since upstream publishes no monochrome SVG - `U+E909` — Ori, from , OpenRouter's own mark: Ori ships no separate logo and its product page uses this one -- `U+E90A` — Hermes, Font Awesome's staff-snake (CC BY 4.0) from , the mark Hermes serves as its favicon: their app icon is a portrait that reads as a smudge at menu size +- `U+E90A` — Hermes, traced from the [official desktop app portrait](https://raw.githubusercontent.com/NousResearch/hermes-agent/2237be355906fbe6065ce1815711eee52b2d646e/apps/desktop/assets/icon.png), the same artwork shipped by `hermes-desktop`. The monochrome trace is kept in [hermes.svg](hermes.svg). - `U+E90B` — Perplexity, from - `U+E90C` — OpenClaw, traced from the lobster mascot the openclaw package ships as `dist/control-ui/favicon.svg`, since upstream publishes no monochrome SVG - `U+E90D` — Cursor, from diff --git a/default/fonts/omarchy/hermes.svg b/default/fonts/omarchy/hermes.svg new file mode 100644 index 00000000..6ce1c6ef --- /dev/null +++ b/default/fonts/omarchy/hermes.svg @@ -0,0 +1,27 @@ + + diff --git a/default/fonts/omarchy/omarchy.ttf b/default/fonts/omarchy/omarchy.ttf index 8d6df78333fe9f3b2d4801516266015b094b6d03..632c91bb5441d37a752681d6d50943ea30e543a3 100644 GIT binary patch delta 4786 zcmai2TWlOx89wLC{mf-&cXnrX*N(GZJKiL9G}qfBSDg!}_<3 ziIa=VFE0H0Utj$!WBPf#ZpSgbI2PeJ+*>^C;Z-8KP-NpQ$82vX^ZoMEY;yvhp z9qW_Jt8+8Gi`~C~eiiE@D>E;w@ey&9jWSMz#?6_P`O#N@_cCMEGGp%BYpWZ#)}Fcj zFk^kc1ph6@@yka?t;aUMu=VZI#a@|FV$sh^-~c0r#KYJ&Eg9Q7$mh2HQ96*aH`oe0 znLTgecbR>HU1Be>+mJCZG>kzE9pjD6DB~wUC5MA}lgT&0y@_|sLQqUL!{*oxSY2i7 z?8STRp1kL8liLUB{GPjA+1~yxo^Ne`ZF>vPmlK}YKo^RNLoUlKVwF3C;!VEwRMcnN zA3C3QzVH0y&Tq6iH!T6{Y3;7|Q(ohb@I}7CU*@myFY+z^4gMy7o8RRh@IMMoM50Ud zF;;4ix5r0EMvWv^k5#tJy-%yz8fYCOsrGmhw+56{YrrzISy^#ftqSy7&2|bgDC|6B zij-(rW_z5}a&8}_@>O3mcS>%OI5RttFKtZ3PZD)4KrSjCy0w$;w|2Re8#>g>^ zB9h&eimug3cMBy^rrYCGL3;Wq0|X7`+Gd;V47C5<)ph9L$nY2!LkFu>*V8m1glYO- z9Fb((L7$o*L0WYp`x=RaZK~sn&#uJBk+WfhUVU858_HvtyF?8T^4=MH+iMp(<+q= z*R(9R7-~8fX%q|YnVwe&JWu!bcWYeA{{1@FVGII>;X~EdeN%9unWj^$Cc<$wQRqKf zb4z{ANb)c&TDGOhs9dRNybvUT>lucW2WBfKq85f>Cx;Io3US+mBSAurs4L~>Mla9mP)m< zBYl?_Ub5|jeYH?7IJTxsY5c_V46R(&{`0OhY=_4})AdjyM}M+iO)De?T@(BRNB2Az z{?FP@ttzyEs&u_#;Q9T2!LZ66N0hc{ScQPUuW6PIVb9dcvB!^bp7coD^&Fvtp(Bf~ z7uJ2h+~B;`XtfUC^*qzG9pog`L@5zk7-)L%oUS?2u>+?E9t1dmEPF*eIxDMb$*tyL`_zG|!I@_cr>w4?oo0$9rnS!>z*5 zk>kflN6HONk3tP#y}*wLYtlujbyw;_y1wr?+>Z*r>(%##p=JACSO~k(f6_vM2#>DY zKE!R1g@vf(Cqco7@q*)7wOVhp(TMvj^H5J%s@IDL z4n*Z1&i6sARI-F+*=5s^p6(=Z9YwZ0&ZFUl=B3LsvllL;<>6-2d&n?_#qs5f5PqAxs zOX?18%7ESJ%mwCmJ9BLhUEeb|aF4#%kumqsCA-T0+@ZT{fIr%q`}TMEOlKajpG$3Z zW$XF!)#uK$dECD%_}yR)wuqbkMYe`}`2<#{aW}7Fz0Q`vxxsFMb`ZDrA-qS}QH(Ln zrXaPMX-{WX=5d3s)7`%HtT{j zu*=ZC-x__Yd<@3)<3PfMWKRPMC1D95H}FfY8`-Ln!CVJ_7Kxw)ts}gffIrW!#XtO{_e^14p- zTw-Zn;|-iBwAC(Wt|&#b5c(X9?v`e^f(@vz!kIcd@`RG>d>`!QV(*r{UkhQSS*I?; z$!z9oZ=0!zi9!xOx=c{MC=;MHa2QR4a}_7v8BouF(u4$Yh~LZ_@>+Hto`>9(>|9hfrXe?l z^|O#A+YNRq1%Z}NL17BUsGeupGntdKFh^!D!l49z64VQLlT(r-jnkl9!J6jhKzpL& za0*)LKqfspoE6l|pf|8mAzouC#g^kD)5;-Xo5X4w4$gN_pMvp;3;;zi2uM$6hNm(u z(t9$4il{Ju8o$$UGyw}&phYX9Ucvim$X$X~Cw@vH;hlo;6pTz~?{g3!2$ZlRkRYCt zqYPc`aHvcK@B*GZE{Qdzozg$OlR>&JFT(Y+nf-I%Qhrhrx_|^ytQ5l&n9)5p0V7Y~ zO{?9wRIw=OrywzzmEjELlNbazFBcU}C7DW>Tictx2Kov`NO#2Y2&qLVg{Q%pfEik; zW+D8P4JsFLsRyW@lxEc>d)-nCQj$rEl25C=ODM+|Gx({*MD?h0LgsQP$|2QvFPOZy z^K^gIPEl6!HoKAm`Co_)b|!^!s-8kd=~H!9suGI#Z$jFl^r3SLyPJ-zAv}vXhG0}h>~`6pHSFb)g4Sm^2L3S7qeQL~OH3GR29I@l<7{)v-Qjn(+?o^fy{3}BL=Fp!r SOm4j|lC4)NufG1y@_zxLm#ohK delta 960 zcmZuwOK1~O6uoz5GBZumNj1s5e$r|?B~xnuY0{#I1+}p%R4mqtn}oDBVq$|yX$wMD zf}&_;L_`!3tGKA8*rhJIlHvyuT)0yZl!y!0QX22eM@w;rlY7s(XYLuun=eE2ZS#5- zUIIX3c%-vu^utgX09%MhV##Rg@wsgWiC+c~4o@cL&K&>w{x^X55kOmwPejK%mUdqU z*nW%rp*RUDN{skh;?417X0{(}Cy4(dK9-n@MSZ8)3jp^a;vLE8YzkX&8BB1`lCbl9 zG&%9^`!`zVzC)pJQd8+nYHX$*z&lR!MJv;su=+8UwGk5uZ3Kz107$=p2T0{z8&!Kj zmw<1sg{p!Qg&4#k2~%(Z=5mm;=Nvg_t}>q|&(;~1XZc5TKgi$CTXf%G$W)b8Fomgr z)I?M+{J)^| zuJGWXQ>Du}*sX@kn$K6%j>rPihzJV%8$51Ax2IvB(8zsgntN$huXeLa#u|LBbv{I2 zU8| zDxSf!RF8m6b5WMYu)MIi7HlvASH)-IG}N#3=wH#n|9=bwa@l*YZ>|l?I#H0UMsY_k zosT|%gO1cC5@Y&j^9 Date: Wed, 9 Sep 2026 11:38:23 +0200 Subject: [PATCH 16/48] Premature to have this included like this Lots of great discussions, but it's for Omarchy Cinque, if it happens. This repo is for Quattro for now. --- plans/nix.md | 150 --------------------------------------------------- 1 file changed, 150 deletions(-) delete mode 100644 plans/nix.md diff --git a/plans/nix.md b/plans/nix.md deleted file mode 100644 index b1a8ce31..00000000 --- a/plans/nix.md +++ /dev/null @@ -1,150 +0,0 @@ -# Plan: Nix — replace Arch with a sovereign Nix foundation - -Revision 2. Rev 2 incorporates adversarial review by codex (xhigh): atomicity restated as atomic selection rather than transactional activation, staged switches for major updates, password hashes kept out of the store, a legal-redistribution gate for unfree packages, precise sovereignty boundaries (mise, fwupd, Steam, Cloudflare), a signed release manifest with anti-rollback, source-rebuild proof in the continuity gate, garbage-collection policy, and a substantially hardened migration: supported-layout gating, live-probed hardware config, an explicit boot transaction with user blessing, state-divergence policy for the shared home, and two-stage rollback. - -## Problem - -Omarchy spends a remarkable amount of its code protecting users from its own package manager. The scars are all pacman-shaped: - -- `omarchy-update-system-pkgs-when-conflicted` is ~150 lines of quarantine choreography — stash unowned conflicting files under `/var/lib/omarchy/replaced`, retry, restore what the upgrade didn't claim — because pacman refuses to own file conflicts. -- The `etc-overrides/` mechanism (`docs/file-layout.md`) exists solely because pacman won't let two packages touch the same `/etc` file, so we ship copies to `/usr/share/omarchy/etc-overrides/` and `cp -f` them into place from scriptlets. -- An ALPM hook (`00-omarchy-update-guard.hook`) aborts direct `pacman -Syu` because updates that bypass `omarchy update` skip the coordination around them — snapshots, migrations, hooks, restart checks; we built a guard to keep users away from the distribution's own tooling. -- The keyring dance in `omarchy-update-keyring` bootstraps trust through `keys.openpgp.org`, and `etc/gnupg/dirmngr.conf` lists five more external keyservers — our signature chain roots outside our infrastructure. -- Updates are not atomic, so we bolted atomicity on: snapper snapshots plus `limine-snapper-sync` approximate what the package manager can't promise, and `docs/update-process.md` still lists pacnew/pacsave handling as an open wound. -- `omarchy-upgrade-to-quattro` is 2,389 lines. That is what it costs to move a fleet of mutable, individually-drifted Arch installs through one package-layout transition. - -And sovereignty is only half-won. We already run the hosting — `mirror.omarchy.org` serves core/extra/multilib, `pkgs.omarchy.org` serves the `[omarchy]` repo, stable deliberately trails upstream Arch by a month (`manual/30-updates.md`) — but we don't own the substance. Arch decides what a "system upgrade" contains and when soname bumps land; we inherit every decision a day later and can only delay it. The AUR path (`omarchy-pkg-aur-*`, the Install menu) executes unsigned build scripts fetched live from `aur.archlinux.org`. T2 Macs add a GitHub-hosted third-party repo with `SigLevel = Never` (`install/hardware/pacman.sh`). And because every install mutates independently, no two Omarchy machines run the same bytes — "we tested this update" is a statement about our machine, not yours. - -Nix fixes the category, not the symptoms. A NixOS system is a closure: one immutable tree of store paths containing every package, config file, and service definition, built once, signed once, and selected atomically — the running system is a symlink flip to a complete generation, and the old one stays bootable. Rollback is booting the previous generation; file conflicts and pacnew files are structurally impossible; and every machine's packages are the byte-identical store paths we built and tested (the thin top-level closure that composes them — hostname, disk UUIDs, the user's package manifest — is assembled per machine; the payload is not). To be precise about what is and isn't atomic: *selecting* a generation is atomic, *activating* one is a sequence — services stop, activation scripts run, services start — and a step in that sequence can fail. The design below stages risky switches across a reboot for exactly that reason. The catch is that the Nix ecosystem assumes nixos.org: `cache.nixos.org` as substituter, nixpkgs from GitHub, channels from `channels.nixos.org`, an install script piped from their web server. This plan takes the technology and none of the hosting. - -## Shape - -- Omarchy becomes a NixOS-based system whose entire supply chain runs on omarchy.org infrastructure: a pinned nixpkgs fork on our git hosting, closures built on our build farm, binaries served from our signed cache. A user's machine never contacts nixos.org, cache.nixos.org, or GitHub for OS concerns — the same posture `pkgs.omarchy.org` and the mirrors have today, extended until it covers everything. -- Users don't learn Nix. The `omarchy` CLI keeps its verbs (`omarchy-pkg-add`, `omarchy update`, `omarchy-channel-set`), `~/.config` stays your mutable files, themes and the refresh pattern are untouched. Nix is plumbing, exactly as pacman was plumbing — it just leaks less. -- An update is: fetch prebuilt, signed store paths from our cache, compose the new generation, activate it — across a reboot when the jump is big — and keep the old generation bootable. What we ship is what we tested, store path for store path. - -## Sovereignty, precisely - -"Sovereign" means two different things at two different times, and the plan should be honest about which is which: - -- **Runtime sovereignty (absolute, for OS delivery)**: an installed machine resolves every OS need — binaries, sources, expressions, signatures, update metadata — against omarchy.org hosts only. No fallback substituters, no keyservers, no GitHub fetches, no upstream flake registry. If nixos.org vanished tomorrow, no user would notice. -- **Build-time sovereignty (continuity)**: our infrastructure ingests from upstream nixpkgs at development time, then archives everything — the nixpkgs tree in our git mirror, every source tarball in our archive, every build product *and its build closure* (sources, patches, derivations, the compilers that made it) in our cache. If upstream vanished, we could keep building, patching, and releasing from what we hold, indefinitely. What we do not claim: re-deriving the world from a bootstrap seed. Nixpkgs' standard binary bootstrap tarballs are part of what we mirror and trust; full source-bootstrap purity is out of scope. - -The boundary is OS delivery, and the plan names what sits outside it rather than letting "absolute" quietly overclaim. `mise`-managed tools pull from GitHub and language registries; fwupd firmware comes from LVFS; Steam downloads Valve's content; browsers update their own components. Those are application-content channels the user chose, not OS delivery, and they keep working — but each gets an explicit decision (mirror it, repoint it, or declare it outside the promise) instead of an assumption. The dev channel's GitHub clone in `omarchy-channel-set` repoints to our git hosting. And Cloudflare stays as the DDoS shield and CDN (`manual/48-security.md`), but the cache origin is storage we control, with a documented path to serve it from elsewhere — a CDN in front of sovereign infrastructure, never the only copy of it. - -The release gate makes this testable, in two parts. Delivery: a release is publishable only if a clean machine, with outbound network restricted to omarchy.org, can install the ISO, update, and install every curated extra. Continuity: from an empty store, with binary substitution disabled and only our source archive reachable, the release closure must rebuild — proving we archived the build inputs, not just the outputs. Sovereignty becomes a CI assertion instead of an aspiration. - -## Rejected approaches - -- **Nix on top of Arch** (Nix as a secondary package manager, Arch stays the base): two package managers, two update pipelines, two failure modes, and the worst properties of both — pacman still owns the system, so none of the atomicity or reproducibility arrives where it matters. The halfway house costs most of the migration and delivers little of the payoff. -- **Guix**: the same functional model with a nicer language, but its FSDG-purist stance on proprietary firmware, microcode, and NVIDIA drivers means fighting the distribution on exactly the hardware enablement (`install/hardware/` is 51 leaves deep) that Omarchy considers table stakes. Nonguix exists; building a product on an unofficial channel the project disowns is not a foundation. -- **cache.nixos.org as fallback substituter**: the tempting hedge — use our cache first, theirs when we miss. It silently converts every gap in our build coverage into an external runtime dependency, which is precisely the failure mode this plan exists to eliminate. Misses should fail loudly and get fixed in our farm, not papered over by someone else's CDN. -- **Hydra for the build farm**: the canonical Nix CI is a sprawling Perl application that is its own operational project. Our release matrix is a known, finite list of targets; plain `nix build` over that list in ordinary CI, followed by `nix copy` to the cache, does the job with tooling we already understand. -- **A live binary-cache daemon** (Attic, Harmonia): a Nix binary cache is narinfo and nar files — static content. Object storage behind Cloudflare is the same shape as the pacman repo we serve today, has no attack surface, and scales for free. A daemon earns its keep only if we later want deduplicating storage across many releases; start dumb. -- **home-manager for user configs**: it would make `~/.config` a farm of read-only symlinks into the store, which is the opposite of Omarchy's "your files" philosophy (`plans/dots.md` exists because those files are yours to edit). The declarative boundary stops at the system layer; the user layer stays mutable plain files. -- **Image-based atomicity instead** (ostree/Silverblue-style, or A/B partitions): atomic, but at image granularity — you get our image or you get nothing, and local package additions become a bolted-on overlay mechanism. Nix gives the same atomicity at package granularity, so `omarchy-pkg-add` keeps meaning something. -- **Staying on Arch and hardening further**: the baseline. Every mitigation above can be polished, but they remain mitigations for structural properties — mutability, non-atomicity, conflict-prone file ownership — that pacman cannot shed. We would be signing up to maintain the workaround museum forever. - -## Design - -### Supply chain - -- **nixpkgs fork**: a mirror of nixpkgs on our git hosting, plus an `omarchy` branch carrying our patches (the successor to `omarchy-pkgs`' PKGBUILD patches). Each release pins an exact revision. Flake inputs reference our tarball endpoint (`https://mirror.omarchy.org/src/nixpkgs-.tar.gz`) with the lockfile's `narHash` pinning content, so even the expression source is fetched from us and integrity-checked. -- **Source archive**: builders fetch upstream sources once, at ingestion; every fixed-output derivation's output is then held in our cache and our source mirror. `hashedMirrors` pointed at omarchy.org covers `fetchurl`, but it is a hint, not a boundary — `fetchgit`, flake fetchers, and language-ecosystem fetchers each need their own mirroring, and a cache miss makes Nix try a local build whose fetcher will happily call GitHub. So the boundary is enforced where it can't be forgotten: builder and client network policy allows omarchy.org only, and a miss *fails loudly* — a hole in our archive is a bug to fix in the farm, never a silent fallback to upstream. Rebuilds never need the original upstream URL to still exist. -- **The omarchy flake**: lives where `omarchy-pkgs` lives today — same repo split as now (this repo is the runtime; the packaging repo owns pins, the overlay of packages nixpkgs lacks, and the NixOS modules; `omarchy-iso` owns the installer). The T2 Mac kernel and the `linux-ptl` kernel move from third-party repos and AUR-adjacent sources into our overlay, built and signed on our farm — which closes today's `SigLevel = Never` hole outright. - -### Binary cache and trust - -- `cache.omarchy.org`: narinfo + nar objects on object storage behind Cloudflare, populated by `nix copy` from the farm, signed with an Omarchy ed25519 cache key. Released objects are write-once (object-locked): a nondeterministic rebuild must never silently replace a narinfo the fleet already trusts. -- Cache signatures authenticate store paths; they do not say "this is the current stable release." That job belongs to a **release manifest**: a small document per channel naming the release version, the exact top-level closure hashes per hardware variant, and an expiry — signed offline with a release key that is *separate* from the cache key, monotonically versioned so a compromised CDN cannot replay last month's release, and re-signed on a cadence so a frozen mirror goes stale loudly. `omarchy-update-available` and the update flow trust the manifest first, paths second. Key hygiene — build key, cache key, release key, rotation, and revocation — is a Phase 0 deliverable with a rehearsed compromise-recovery runbook, not an appendix. -- Client `nix.conf` (owned by our NixOS module, not user-editable state): `substituters = https://cache.omarchy.org` — nothing else, replacing the default cache.nixos.org entirely; `trusted-public-keys` lists only our key; the flake registry is pinned to our own registry file so bare flake references cannot reach GitHub. -- Trust roots: the cache and release public keys ship inside the ISO and the installed closure. `keys.openpgp.org`, `archlinux-keyring`, `omarchy-update-keyring`, and the five keyservers in `etc/gnupg/dirmngr.conf` all leave the OS trust path (gnupg remains for the user's own purposes). - -### Build farm - -Our own builders run `nix build` over the release matrix: the base system closure per hardware variant (NVIDIA open/legacy, T2, `linux-ptl`, plain), every optional package behind the Install menu and `omarchy-install-*`, and the ISO. A release job then verifies the gate: every store path in every target closure must be substitutable from `cache.omarchy.org` before the release tag is signed. Nothing a user can reach through blessed UI may miss the cache. - -One gate is legal, not technical: nixpkgs distinguishes redistributable-unfree from unfree-you-may-not-redistribute, and serving a package from our cache *is* redistribution. NVIDIA userspace drivers (nixpkgs patches them), VS Code, Chrome, vendor firmware, and printer blobs each need a per-package answer in Phase 0: confirmed redistribution rights, a redistributable substitute (VSCodium-shaped choices), or a blessed vendor-fetch exception — which is a named, per-package hole in the runtime-sovereignty claim, recorded as such rather than discovered later. No package enters the curated set without landing in one of those three buckets. - -### The system layer - -- Everything under `install/config/`, `install/hardware/`, and the `etc/` tree becomes NixOS module code: `services.displayManager.sddm`, `boot.plymouth`, snapper, docker, cups hardening, the sysctl/sudoers/tmpfiles drop-ins, the NVIDIA modprobe and initrd logic that today lives as conditional bash inside `etc/mkinitcpio.conf.d/omarchy_hooks.conf`. `omarchy-apply-system` and `omarchy-apply-hardware` become module imports plus hardware-variant selection instead of sourced shell leaves — and the entire `etc-overrides/` mechanism is deleted, because composing `/etc` from multiple sources is what the module system is. -- **Bootloader**: limine stays — NixOS ships a `boot.loader.limine` module — but its job changes: boot entries are system generations, not snapper snapshots, so `limine-snapper-sync` and `limine-mkinitcpio` retire. The UKI and fallback-entry behavior configured in `etc/limine-entry-tool.d/` and the direct-boot path (`omarchy-setup-direct-boot`) must be reproduced deliberately — upstream's limine/UKI story is still settling — and boot security is its own workstream: Secure Boot stays explicitly unsupported (as `manual/02-getting-started.md` says today) unless that workstream designs key enrollment, measurement, and recovery properly; it does not sneak in as a module default. -- **Per-machine composition, budgeted**: the cache delivers every package prebuilt, but each machine still evaluates and assembles its thin top-level closure — `/etc`, initrd, activation scripts — locally on every switch. That cost is real on low-end hardware and gets a measured budget (time and memory, on the weakest supported machines) in the acceptance suite, not an assumption that "everything substitutes, so it's fast." -- **A supported customization layer**: `/etc` becoming module-owned cannot mean "hope nobody needed to change it." Mounts, sudo rules, kernel parameters, and service tweaks are system concerns with no home-directory equivalent, so the machine gets a blessed local-override file the modules import — real Nix options, documented, surviving updates — and every managed `/etc` file has a named owner. Coordination that today hides behind the pacman guard (migrations, hooks, restart markers) moves into activation-time logic keyed by release version, so even a user running `nixos-rebuild` directly cannot skip it: `omarchy update` stays the pleasant path, but correctness no longer depends on being the only path. -- **Store hygiene**: closures don't orphan, but unreferenced store paths accumulate and old generations are what rollback is made of — so garbage collection is policy, not an afterthought: automatic GC with a generation-retention window, a cap on boot-menu generations, and a free-space floor, sized so the store's steady state on a user disk compares honestly with today's pruned pacman cache. -- **Filesystem**: btrfs stays for `/home` (snapper's remaining job: user-file snapshots, until `plans/backup.md` and `plans/dots.md` cover that ground) and for `omarchy-system-factory-reset`'s subvolume mechanics — though the reset workflow itself (the `@factory` baseline, UKI rebuild, LUKS re-key) must be ported, and the restore guarantee narrows honestly: booting an old generation restores the OS, not mutable `/var` state the old root snapshots used to carry. `omarchy-snapshot restore` for the OS becomes "boot the previous generation." - -### The user layer stays mutable - -Non-negotiable: `~/.config` remains plain files the user owns and edits. `/etc/skel` seeding, `omarchy-refresh-config`, themes, and the entire `default/` → `~/.config` pipeline work unchanged. The declarative world ends at the system/user boundary; crossing it (home-manager) is rejected above. This is the line that keeps Omarchy feeling like Omarchy rather than like NixOS. - -### Package UX - -- The machine grows a package manifest — a plain text list in the spirit of `install/omarchy-base.packages`, owned by the machine, listing what this user added. `omarchy-pkg-add ` resolves the name (an alias table maps established Arch names to nixpkgs attributes, so muscle memory and the menu's package names keep working), appends to the manifest, and rebuilds against our cache — prebuilt, so "rebuild" means download, a local re-evaluation, and a switch: never a compile, and held to the per-machine composition budget above rather than assumed fast. `omarchy-pkg-drop` removes and rebuilds. `pkg-present`/`pkg-missing` query the running closure. -- The Quickshell menu's guard prelude (`shell/plugins/menu/MenuModel.js` snapshots `pacman -Qq` plus a Provides parse because forking per guard "spends over a second") gets simpler and faster: one listing of the current closure's package set, computed at activation time and cached, replaces the pacman queries. -- **The AUR is gone, replaced by the curated extras set**: everything the Install menu offers today (Chrome, Brave, Zen, VS Code, Steam and the lib32 Vulkan stack via nixpkgs' 32-bit support, and friends) comes from nixpkgs or our overlay, built and signed on our farm — the first time Omarchy's optional software carries the same signature chain as its core. Arbitrary AUR browsing (`omarchy-pkg-aur-install`) has no sovereign equivalent and is not replaced. The escape hatch for power users — adding their own flakes or substituters — is real Nix, documented as leaving the supported, sovereign envelope, and never wired into blessed UI. - -### Updates, channels, migrations - -- `omarchy-update` keeps its skeleton — transcript, lock, free-space check, confirm, stay-awake, migrations, hooks, `omarchy-update-restart` — and swaps its heart: the pacman transaction becomes "download the release closure from the cache, then switch." Failure before activation leaves the running system untouched, and the failed download costs nothing. Activation itself is the sequence that can still hurt — services stop, scripts run, services start — so routine updates switch live, while kernel and other big jumps stage as the *next boot's* generation and activate through the reboot `omarchy-update-restart` already prompts for. `omarchy-update-analyze-logs` survives with a shorter beat: activation and service-restart failures still deserve forensics; package transactions no longer do. And rolling back a generation rolls back the OS, not `/var` — a service that migrated its database forward needs its own story, which is what snapshots-before-update remain for. -- Deleted outright, with the failure modes they existed for: `omarchy-update-keyring`, `omarchy-update-pkg-prune`, `omarchy-update-system-pkgs-when-conflicted`, `omarchy-update-pacman-guard` and the ALPM hooks, `omarchy-update-orphan-pkgs` (replaced by the GC policy above), `omarchy-update-aur-pkgs`, and the pacnew concern. The guard's job — "don't update behind Omarchy's back" — is covered by the activation-time coordination described above, which runs no matter who triggers the switch. -- **Channels**: `stable`/`rc`/`edge` become branches of the omarchy flake with their own nixpkgs pins and their own cache prefixes, mirroring today's three pacman.conf templates. `omarchy-channel-set` flips the flake reference and switches. `dev` keeps its meaning: a local checkout via `omarchy-dev-link`, with `omarchy update` fast-forwarding it as now. -- **Version**: real at last. `omarchy-version` reports the release tag of the running closure instead of deriving it from `pacman -Q`; `omarchy-update-available` compares that against a small release-manifest JSON on the cache host instead of running `checkupdates`. -- **Migrations** (`migrations/`, 94 files) shrink to their legitimate residue: user-space state under `$HOME`. The 14 that touch pacman/limine/mkinitcpio have no successors — system-state transitions become module code that is simply part of the next closure. The per-user marker mechanism and `omarchy-migrate-notify` survive for what remains. - -### ISO and installer - -`omarchy-iso` rebuilds around a NixOS ISO carrying the full release closure in its store. Offline installation becomes `nix copy` from the ISO's store to the target plus writing the hardware module selection and the machine manifest — structurally the same "offline mirror" trick the ISO does today with pacman packages, minus the post-install `pacman.conf` restore dance (`install/post-install/pacman.sh`). The ISO signature chain (`iso.omarchy.org`, `.sig`) is unchanged. - -## Migrating from Quattro to Cinque - -`omarchy-upgrade-to-quattro`'s 2,389 lines are the cautionary tale for what in-place transitions cost — and that one didn't change the package manager. But Quattro's standard disk layout is the opportunity: root on a btrfs subvolume (`@`) with `/home` on its own (`@home`), inside one LUKS container, under a bootloader that already knows how to offer multiple roots. That layout lets Cinque move in *beside* Quattro instead of on top of it. - -### The parallel-root migration - -`omarchy-upgrade-to-cinque` ships as an ordinary Quattro package update, the same delivery path the v3→v4 upgrader used. It never runs unprompted — migration is an explicit user action, announced through the usual channels, never something `omarchy update` springs on anyone. - -1. **Preflight, running system untouched**: the migrator supports the standard layout — btrfs root on `@`, `/home` on `@home`, one LUKS container, limine — and *refuses* everything else (LVM, RAID, exotic mount graphs, hand-built boot chains) toward the reinstall path; `omarchy-system-factory-reset` already gates on the same layout for the same reason, and for boot and storage, "I don't recognize this" is a blocker, not a warning. Then: a hardware gate — the machine's variant (NVIDIA generation, T2, `linux-ptl`) must have a built Cinque closure in the cache, or the migrator refuses with "not yet" rather than "hope so"; a space gate computed from the actual NAR sizes the cache reports plus the retained Quattro root, btrfs metadata headroom, and ESP room for both systems' boot artifacts (the fixed 10 GiB check in `omarchy-update-requires-free-space` is not an estimator); hibernation detection — a suspended image or the swap-subvolume setup from `omarchy-hibernation-setup` is invalidated and its resume configuration carried or rebuilt, because resuming one OS's hibernation image from the other corrupts the filesystem; and the inventory that feeds the *won't-survive report* (see below), which the user reads before consenting. -2. **Fetch**: the release closure downloads from `cache.omarchy.org` into a fresh `@cinque` subvolume's `/nix` store — resumable, verifiable against signatures, and entirely inert while Quattro keeps running. The sovereignty gate applies here too: the whole migration touches only omarchy.org hosts. -3. **Carry state**: the partition table, LUKS container, and `@home` are untouched — Cinque mounts the same `/home`. The machine's module configuration is generated from the *live* system — current mounts, `fstab`, `crypttab`, `lsblk`, `/proc/cmdline` — not from a replay of historical hardware detection, and the generated initrd is validated before anything is asked to boot from it. Accounts carry as the full database, not a hash import: `/etc/passwd`, `/etc/shadow`, groups, and NixOS's ID-stability state move as root-only files with `users.mutableUsers` on — password hashes must never be interpolated into the world-readable store. `machine-id`, SSH host keys, and NetworkManager connections come along; `/var/lib` payloads that are data rather than OS (docker volumes chief among them) are copied with their services stopped — a reflink copy of a live database is cheap and worthless. -4. **First boot, Quattro still the default**: the migrator adds a Cinque boot entry inside a deliberate boot transaction — the ESP contents and firmware boot variables are inventoried and backed up first, foreign entries (Windows, other distros, the fallback loader) are preserved, and machines using `omarchy-setup-direct-boot`'s NVRAM path get that path handled explicitly. The user boots Cinque by choosing it; limine has no proven boot-once/boot-counting mechanism today, so *blessing is a human act*: first-boot verification (graphical session reached, network up, closure healthy) presents its results and asks before Cinque becomes the default. A failed boot needs no cleverness — the default was still Quattro, and a diagnostic bundle waits for `omarchy-upload-log`. -5. **Rollback window, then reclaim**: at cutover the migrator snapshots `@home` — the two systems share a live home from here on, and applications will migrate profiles and state forward in formats the old side may not read, so a real return to Quattro needs that anchor to offer. `omarchy-upgrade-to-cinque --rollback` is two-stage by construction: it makes Quattro the default and reboots into it; only then, from the running Quattro, does it offer to restore the home snapshot (with post-cutover writes preserved alongside, never silently discarded) and remove `@cinque` — a system never deletes the root it is running on. In the other direction, `--reclaim` (or the update pipeline, after enough clean boots — open question) deletes the Quattro root and returns the space. - -Rollback is a reboot plus a decision about state, and the plan says so — the OS comes back untouched by menu choice; the shared home's forward drift is what the cutover snapshot exists to answer. That is still a property no in-place mechanism can offer, and it is what makes offering the migration to a fleet responsible rather than reckless. - -One wrinkle owned explicitly: during the window, exactly one side owns the bootloader — Cinque, from the moment its entry is blessed. The Quattro root is kept bootable but frozen — the migrator's only writes into it are disabling `limine-snapper-sync` and the update timers, because two operating systems regenerating one boot configuration is how both stop booting. Booting Quattro during the window is for rescue and rollback, not for continued dual life; the way back to a *living* Quattro is `--rollback`, which returns bootloader ownership along with the default. - -### The won't-survive report - -Some of what a Quattro machine accumulated has no Cinque equivalent, and the preflight says so per-machine, before anything changes: - -- **Packages the user added**: the delta of `pacman -Qqe` against the Quattro release baseline (the raw list would drown the signal in the base system), run through the alias table into the manifest; AUR packages without an overlay equivalent (`pacman -Qem` minus the curated set) are listed by name with the escape-hatch documentation linked. Not a blocker — the user decides. -- **Custom pacman repos**: both the `pre-refresh-pacman.d` hook layer and repos hand-added to `pacman.conf`, named as unsupported since the mechanism itself retires. -- **System-level drift**: `pacman -Qii` backup-file diffs are the start, not the whole story — the scan also covers unowned files in `/etc` (`omarchy-update-system-pkgs-when-conflicted`'s quarantine logic proves we can tell), package-file divergence via `pacman -Qkk`, locally enabled or masked systemd units and drop-ins, DKMS modules, printer configuration, and firewall rules. Everything found is listed so the user can carry the *intent* forward — into `~/.config`, an Omarchy setting, or Cinque's local-override module — instead of silently losing edits. Drift in boot or storage configuration is a blocker, per the preflight. - -Per-user state needs no migration at all: migration markers, themes, and everything else under `/home` ride along on `@home`. Dev-link users get their checkout fast-forwarded onto the Cinque branch by the migrator rather than a package swap. - -### Rejected migration paths - -- **`NIXOS_LUSTRATE` in place**: the historical takeover mechanism mutates the only root the machine has — a failure mid-lustrate is an unbootable machine and a restore from backup — and upstream is deprecating it (it doesn't work with the now-default systemd initrd, and NixOS's own guidance points at install-to-another-root instead, which is exactly what the parallel subvolume is). The parallel root delivers everything lustrate promised, plus a rollback that is just a boot-menu choice. Machines without room for two roots get "free up space first," not a reason to lose the rollback. -- **Reinstall as the only path**: always supported, documented, and cheap once `plans/backup.md` and `plans/dots.md` land (which this plan therefore treats as prerequisites, not nice-to-haves) — but a migration path only matters if the fleet actually takes it, and "back up, reflash, restore" is where fleets quietly decide to stay behind. Reinstall is the fallback, not the offer. -- **Automatic migration through `omarchy update`**: never. Changing a user's operating system's foundation is a decision, not an update. - -## Rollout - -- **Phase 0 — infrastructure, zero user impact**: nixpkgs mirror and tarball endpoint, source archive, `cache.omarchy.org`, the key hierarchy (build/cache/release, offline signing workflow, compromise runbook), the signed release-manifest format, the legal-redistribution inventory for the curated set, the build farm, and a CI job that builds the current desktop's equivalent closure and proves both halves of the sovereignty gate (delivery with outbound network restricted to omarchy.org; rebuild from the source archive with substitution disabled). -- **Phase 1 — system parity** (packaging repo, with changes here): NixOS modules covering every `install/config/`, `install/hardware/`, and `etc/` entry; the flake with per-channel pins; boots and passes the graphical acceptance suite in the VM (`agents/skills/acceptance-tests.md`). -- **Phase 2 — CLI port** (this repo): `pkg-*`, `update-*`, `channel-*`, `version-*`, snapshot/restore semantics, menu guards; delete the pacman-only organs; port the 18 pacman/yay-mocking test files in `test/shell.d/` to the new seams. -- **Phase 3 — ISO and installer** (`omarchy-iso`): the offline NixOS ISO, installer flow, hardware detection wiring into module selection. -- **Phase 4 — release and overlap**: ship as the next major; maintain the Quattro channels in parallel through the overlap window; deliver `omarchy-upgrade-to-cinque` as a Quattro package update, with the reinstall-with-restore path documented as the fallback. -- **Docs and tests**: `docs/update-process.md` rewritten around the switch model; a new `docs/` reference for the sovereignty gate and cache/mirror topology; manual chapters for updating, rollback-by-generation, and the extras set; shell tests for manifest editing, alias resolution, channel flips, and guard-free update flow; switch-time and evaluation budgets measured on the weakest supported hardware in the acceptance suite; the release-gate CI assertion is itself the sovereignty test. - -## Open questions - -1. **Which Nix**: upstream CppNix is the safe default; Lix is an argument about governance and pace we don't strictly need to have while we're rehosting everything anyway. Whichever we pick, users get it from our ISO and our cache — never from an install script on someone else's domain. -2. **Flakes or stable evaluation**: flakes are the ecosystem's lingua franca but formally still experimental upstream. Since we pin our own Nix, we can adopt flakes and own the flag — or use plain evaluation with explicit pins and lose some tooling. Leaning flakes; deserves a deliberate decision. -3. **How far the curated extras set reaches**: nixpkgs holds ~100k packages; we will build and cache hundreds, not all of it. What is the story when a user wants a package outside the set — a request pipeline into the overlay, the documented unsupported escape hatch, or both? -4. **Reclaim policy** for the migration's rollback window: does the retained Quattro root get deleted only by explicit `--reclaim`, or automatically after N clean Cinque boots — and how long is a responsible default window on space-constrained disks? -5. **Btrfs by default, still**: with system rollback moved to generations, btrfs earns its place only through `/home` snapshots and factory reset. Keep it, or simplify the default filesystem story? -6. **Naming and posture**: "powered by Nix" is a fact; "a NixOS derivative" is a relationship with trademark and community expectations attached. How loudly do we say which — and does sovereign rehosting change what we ought to call it? From 5ead870507dfb68db696b3ddb948cc3d178e8d62 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 9 Sep 2026 15:13:43 +0200 Subject: [PATCH 17/48] Add basecamp (basecamp-cli) as a lazy-installed mise tool (#10943) Mirrors the hey-cli stub: the wrapper in ~/.local/bin installs and upgrades through mise on first run, so the CLI tracks releases instead of going stale as a manually dropped binary. Co-authored-by: Claude Opus 5 (1M context) --- install/user/mise.sh | 1 + migrations/1788941927.sh | 5 +++++ 2 files changed, 6 insertions(+) create mode 100644 migrations/1788941927.sh diff --git a/install/user/mise.sh b/install/user/mise.sh index 8106038e..81e0364c 100644 --- a/install/user/mise.sh +++ b/install/user/mise.sh @@ -18,6 +18,7 @@ omarchy-cmd-missing cursor-agent && omarchy-mise-install cursor-agent omarchy-mise-install npm:@kitlangton/ghui ghui omarchy-mise-install aqua:modem-dev/hunk hunk omarchy-mise-install github:basecamp/hey-cli hey +omarchy-mise-install github:basecamp/basecamp-cli basecamp omarchy-mise-install github:OpenRouterLabs/ori-releases ori # Every line above writes a stub and cannot fail. This one can: it exits # non-zero when Hermes Desktop owns Hermes but has not finished setting it up, diff --git a/migrations/1788941927.sh b/migrations/1788941927.sh new file mode 100644 index 00000000..f0f37865 --- /dev/null +++ b/migrations/1788941927.sh @@ -0,0 +1,5 @@ +echo "Install basecamp (basecamp-cli) via mise wrapper" + +if [[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]; then + omarchy-mise-install github:basecamp/basecamp-cli basecamp +fi From 9d0849f71754bf02440fab0f06a9184c749c88ab Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Mon, 7 Sep 2026 20:51:45 -0500 Subject: [PATCH 18/48] Add the Claude desktop app to Install > AI Follows the ChatGPT flow: the Install > AI entry runs omarchy-install-ai-claude in a floating terminal, which installs the claude-desktop package (Anthropic's Linux desktop beta, repacked from their Debian repo in omarchy-pkgs) and opens the app. Remove > AI drops the package along with ~/.config/Claude and ~/.cache/Claude, the Electron directories the desktop app owns, while keeping ~/.claude, ~/.claude.json, and ~/.cache/claude-cli-nodejs: those belong to the Claude Code CLI, which ships in its own package and survives this removal, just as the ChatGPT remover keeps the Codex CLI. The menu mark is a new U+E90E glyph in the Omarchy icon font, from Simple Icons' Claude mark, so it reaches desktops through the next omarchy-settings release. Co-Authored-By: Fable 5 Co-Authored-By: Codex XHigh --- bin/omarchy-install-ai-claude | 15 +++++++++++++++ bin/omarchy-remove-ai-claude | 19 +++++++++++++++++++ default/fonts/omarchy/README.md | 1 + default/fonts/omarchy/omarchy.ttf | Bin 9440 -> 9972 bytes default/omarchy/omarchy-menu.jsonc | 2 ++ manual/17-ai.md | 2 +- test/shell.d/menu-test.sh | 2 +- test/shell.d/remove-ai-test.sh | 18 ++++++++++++++++++ 8 files changed, 57 insertions(+), 2 deletions(-) create mode 100755 bin/omarchy-install-ai-claude create mode 100755 bin/omarchy-remove-ai-claude diff --git a/bin/omarchy-install-ai-claude b/bin/omarchy-install-ai-claude new file mode 100755 index 00000000..85a7e593 --- /dev/null +++ b/bin/omarchy-install-ai-claude @@ -0,0 +1,15 @@ +#!/bin/bash + +# omarchy:summary=Install the Claude desktop app +# omarchy:requires-sudo=true + +set -euo pipefail + +echo "Installing Claude..." +omarchy-pkg-add claude-desktop + +echo "Opening Claude..." +setsid uwsm-app -- /usr/bin/claude-desktop >/dev/null 2>&1 & + +echo "" +echo "Claude has been installed." diff --git a/bin/omarchy-remove-ai-claude b/bin/omarchy-remove-ai-claude new file mode 100755 index 00000000..9c66c5d0 --- /dev/null +++ b/bin/omarchy-remove-ai-claude @@ -0,0 +1,19 @@ +#!/bin/bash + +# omarchy:summary=Remove the Claude desktop app along with its configuration and caches. +# omarchy:requires-sudo=true + +# -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /. +set -euo pipefail + +omarchy-pkg-drop claude-desktop + +# Not ~/.claude, ~/.claude.json, or ~/.cache/claude-cli-nodejs: those belong +# to the Claude Code CLI, which ships in a different package that survives +# this one. The desktop app keeps its state in its own Electron directories. +rm -rf \ + "$HOME/.config/Claude" \ + "$HOME/.cache/Claude" + +echo "" +echo "Claude has been removed." diff --git a/default/fonts/omarchy/README.md b/default/fonts/omarchy/README.md index b731549d..df93aa55 100644 --- a/default/fonts/omarchy/README.md +++ b/default/fonts/omarchy/README.md @@ -16,6 +16,7 @@ The private-use glyphs in `omarchy.ttf` are: - `U+E90B` — Perplexity, from - `U+E90C` — OpenClaw, traced from the lobster mascot the openclaw package ships as `dist/control-ui/favicon.svg`, since upstream publishes no monochrome SVG - `U+E90D` — Cursor, from +- `U+E90E` — Claude, from The agent marks are monochrome so the menu can render them using the active theme's foreground and selection colors. diff --git a/default/fonts/omarchy/omarchy.ttf b/default/fonts/omarchy/omarchy.ttf index 632c91bb5441d37a752681d6d50943ea30e543a3..3c70d8031f15323654523a51fe288297c945edd7 100644 GIT binary patch delta 968 zcmZ9KO=uHa6vxkI4VsikSNsJ5wy_*uozkX8duY^#-5 zL5bkz!2>@Sa8XbZb)|?>9=wGMw}K!-5d=XOZXybbx;37e){W=#pY#8nbLQO5eP8&# z<65)|PXWN$xu>^(|Et0T0O>?NUaA!5?>pgb$o~LvQzxDCCr*rB?ggN$0Fj5~+2Tyk zqv0z6u}e5VSjK`(coX@5p=qH&=>F^Tg&hg9Y(2 z7B-wNR%Y#g-(JK4yfFA=e(vmIq4CoLfQ}LD|K~1d9CwJ6ssa*4b)$IHJs|i6GLBWx zbz@fiIuA>jnd^YIr=wYlZ!nIBwX?Ei@anCwH|^?A(4-P_^9~CWY{{ zAVp*LK$j3x6HHY@sv;3#a96$|K|==zTe;7h3@$K1oKip_l;?%GrgFSU6_KYSV;RF} zPjFm3JFfCnIdsHgn%*G@`d@n&(kVq8tL~ghwl)T1;jK;~(6=L%4tI6+ca7ORQ&c_F zVi+xi6N%hp8cc81_|Ea^NSKT!bCP*^qb3WJyC&(+ueuUWW+%5ym}KhoR#A^d7q)E_ zmxXXL(1%aOe`Cb9?N8s{9Se>8fS~sX0Y1EZeh#j`85NmFyql(}A?)N)%c#n__t?|J z_3U6CL+wFbK>dx%yC&ZwE3f^lnA<`UD-m-wNSOPvt(zkaZZvaL5lc?-+{`TA^$SK~ Bv?l-n delta 420 zcmZ8cJxBs!7=FI%Dmw`RJvD16Dj~Rj@E}5h+{z&wglLK`Gac^qDno^$Er_HaK8moR zp;ZEL%)N#}iiU=$)?1n!+OF3dEZT zuO$u5{H*=llYR$;Xj(6AKL1{90pcRydCH`;jmf*n3EgcFXEJ2CnCPZ+i#VDw@ AI_ menu also carries a few graphical AI apps: the ChatGPT desktop app, Grok Bot for chatting with xAI's models, Hermes Desktop, OpenClaw, and the Perplexity desktop app. +The _Install > AI_ menu also carries a few graphical AI apps: the ChatGPT desktop app, the Claude desktop app (Anthropic's Linux beta, with Chat, Cowork, and Claude Code tabs), Grok Bot for chatting with xAI's models, Hermes Desktop, OpenClaw, and the Perplexity desktop app. Hermes Desktop is the one to know about, because there is only ever one Hermes on a machine. The app only runs against a runtime built from its own commit, so it installs one of its own under `~/.hermes` on first launch, which takes a few minutes and shows its own progress. From then on that is the Hermes the terminal `hermes` command and the default agent use too, whichever order you installed them in. Installing it also hands Hermes the Omarchy theme as a skin named `omarchy`, which every Hermes surface follows as you switch themes; pick another under Hermes' Appearance settings or with `/skin` if you'd rather it didn't, and Omarchy leaves that choice alone. Removing the app under _Remove > AI_ takes that runtime with it, and keeps your chats, memories, and the skills Hermes wrote for itself unless you tell it otherwise: it asks, defaulting to no, whether that data and your connection settings should go too. diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index b227bb10..a424379b 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -641,5 +641,5 @@ assert( JS font_charset=$(fc-query --format='%{charset}' "$ROOT/default/fonts/omarchy/omarchy.ttf") -[[ $font_charset == *"e900-e90d"* ]] || fail "Omarchy icon font includes every custom menu glyph" +[[ $font_charset == *"e900-e90e"* ]] || fail "Omarchy icon font includes every custom menu glyph" pass "Omarchy icon font includes the official agent marks" diff --git a/test/shell.d/remove-ai-test.sh b/test/shell.d/remove-ai-test.sh index 9d518318..7c8ae043 100644 --- a/test/shell.d/remove-ai-test.sh +++ b/test/shell.d/remove-ai-test.sh @@ -55,6 +55,24 @@ pass "ChatGPT removal keeps the Codex CLI's runtime cache" [[ -d $HOME/.codex ]] || fail "ChatGPT removal keeps the Codex CLI's config" pass "ChatGPT removal keeps the Codex CLI's config" +# The Claude Code CLI ships in its own package and keeps its state in +# ~/.claude, ~/.claude.json, and ~/.cache/claude-cli-nodejs, so removing the +# desktop app must not take it. +fresh_home +mkdir -p "$HOME/.config/Claude" "$HOME/.cache/Claude" "$HOME/.cache/claude-cli-nodejs" "$HOME/.claude" +touch "$HOME/.claude.json" +"$ROOT/bin/omarchy-remove-ai-claude" >/dev/null + +for gone in .config/Claude .cache/Claude; do + [[ ! -e $HOME/$gone ]] || fail "Claude removal deletes the desktop app's config and caches" "$gone" +done +pass "Claude removal deletes the desktop app's config and caches" + +for kept in .claude .claude.json .cache/claude-cli-nodejs; do + [[ -e $HOME/$kept ]] || fail "Claude removal keeps the Claude Code CLI's state" "$kept" +done +pass "Claude removal keeps the Claude Code CLI's state" + # LM Studio's models follow a relocatable home, named only by the pointer file. fresh_home mkdir -p "$tmp_dir/relocated-models/models" From e3392ef76fb5ebd2570be3b322546990047dc6dd Mon Sep 17 00:00:00 2001 From: Spencer Bull Date: Thu, 10 Sep 2026 19:26:34 -0500 Subject: [PATCH 19/48] Quit the running Claude app before removing it Electron rewrites ~/.config/Claude for as long as the app runs, so removing the package and deleting the directory while it is open leaves the app running and the directory back within the same second, holding fresh Preferences and crash-reporter state. Driven end to end on an edge worker: remove via the menu with the app open left 11 processes and a recreated ~/.config/Claude; with the app quit first the directory stayed gone. The test stubs pkill so the suite cannot take a developer's own Claude with it, and asserts the remover reached for it. Co-Authored-By: Fable 5.1 Co-Authored-By: Codex XHigh --- bin/omarchy-remove-ai-claude | 4 ++++ test/shell.d/remove-ai-test.sh | 11 +++++++++++ 2 files changed, 15 insertions(+) diff --git a/bin/omarchy-remove-ai-claude b/bin/omarchy-remove-ai-claude index 9c66c5d0..3df3c105 100755 --- a/bin/omarchy-remove-ai-claude +++ b/bin/omarchy-remove-ai-claude @@ -6,6 +6,10 @@ # -u so an unset HOME is an error rather than a set of rm -rf paths rooted at /. set -euo pipefail +# The app rewrites ~/.config/Claude for as long as it runs, so a removal that +# leaves it running deletes the directory and watches it come straight back. +pkill -x claude-desktop || true + omarchy-pkg-drop claude-desktop # Not ~/.claude, ~/.claude.json, or ~/.cache/claude-cli-nodejs: those belong diff --git a/test/shell.d/remove-ai-test.sh b/test/shell.d/remove-ai-test.sh index 7c8ae043..d81e6ca6 100644 --- a/test/shell.d/remove-ai-test.sh +++ b/test/shell.d/remove-ai-test.sh @@ -15,6 +15,14 @@ printf 'drop:%s\n' "$*" >>"$TEST_LOG" SCRIPT chmod +x "$tmp_dir/bin/omarchy-pkg-drop" +# omarchy-remove-ai-claude quits the running app before deleting its state; +# a real pkill here would take the developer's own Claude with it. +cat >"$tmp_dir/bin/pkill" <<'SCRIPT' +#!/bin/bash +printf 'pkill:%s\n' "$*" >>"$TEST_LOG" +SCRIPT +chmod +x "$tmp_dir/bin/pkill" + # omarchy-remove-ai-perplexity asks through gum whether the user's data goes # too. The stub answers "no" unless a test says otherwise and logs the call: a # real gum would hang the run, and one that answered "yes" on its own would be @@ -73,6 +81,9 @@ for kept in .claude .claude.json .cache/claude-cli-nodejs; do done pass "Claude removal keeps the Claude Code CLI's state" +grep -qx 'pkill:-x claude-desktop' "$TEST_LOG" || fail "Claude removal quits the running app before deleting its state" +pass "Claude removal quits the running app before deleting its state" + # LM Studio's models follow a relocatable home, named only by the pointer file. fresh_home mkdir -p "$tmp_dir/relocated-models/models" From 262a779246f7d98e3dff3936666b30fa5f4d98e1 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Thu, 10 Sep 2026 22:57:04 -0400 Subject: [PATCH 20/48] Activate T3 theme on existing installs --- migrations/1789091250.sh | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 migrations/1789091250.sh diff --git a/migrations/1789091250.sh b/migrations/1789091250.sh new file mode 100644 index 00000000..1060d714 --- /dev/null +++ b/migrations/1789091250.sh @@ -0,0 +1,4 @@ +echo "Activate the Omarchy theme for existing T3 Code installs" + +omarchy-pkg-present t3code-bin || exit 0 +omarchy-install-ai-t3-code From 31bd80daa4613ffdee995ac27467fce5a2990806 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 11 Sep 2026 21:51:13 +0200 Subject: [PATCH 21/48] Keep the KEF LSX II LT USB sink from suspending (#11318) The speaker's USB firmware stops answering control requests when the host stops the audio stream after WirePlumber's 5 s idle suspend. The kernel then logs usb_set_interface failed (-110), clock source 1 is not valid, and cannot set freq 48000 err -110; PipeWire fails to start the sink and only a replug recovers it. On one machine this happened on six days over three weeks, up to hundreds of timeouts a day. A WirePlumber rule sets session.suspend-timeout-seconds = 0 for the KEF node only, so the stream is never stopped and the trigger never fires. Other sinks keep the default. The migration seeds the file for existing installs and restarts WirePlumber if it is running, since conf.d is only read at startup. Co-authored-by: Claude Fable 5.1 --- .../kef-lsx-no-suspend.conf | 19 +++++++++++++++++++ migrations/1789130779.sh | 9 +++++++++ 2 files changed, 28 insertions(+) create mode 100644 config/wireplumber/wireplumber.conf.d/kef-lsx-no-suspend.conf create mode 100644 migrations/1789130779.sh diff --git a/config/wireplumber/wireplumber.conf.d/kef-lsx-no-suspend.conf b/config/wireplumber/wireplumber.conf.d/kef-lsx-no-suspend.conf new file mode 100644 index 00000000..02955660 --- /dev/null +++ b/config/wireplumber/wireplumber.conf.d/kef-lsx-no-suspend.conf @@ -0,0 +1,19 @@ +## Never suspend the KEF LSX II LT USB sink. +## The speaker's USB firmware stops answering control requests when the host +## stops the stream after idle (usb_set_interface -110), and only a replug +## recovers it. Keeping the stream open avoids the trigger. + +monitor.alsa.rules = [ + { + matches = [ + { + node.name = "~alsa_output.usb-KEF_LSX_II_LT.*" + } + ] + actions = { + update-props = { + session.suspend-timeout-seconds = 0 + } + } + } +] diff --git a/migrations/1789130779.sh b/migrations/1789130779.sh new file mode 100644 index 00000000..56d9fe20 --- /dev/null +++ b/migrations/1789130779.sh @@ -0,0 +1,9 @@ +echo "Keep the KEF LSX II LT USB sink from suspending" + +conf="wireplumber/wireplumber.conf.d/kef-lsx-no-suspend.conf" + +if [[ ! -f "$HOME/.config/$conf" ]]; then + omarchy-refresh-config "$conf" + # WirePlumber only reads conf.d at startup; restart it if it is running. + systemctl --user try-restart wireplumber.service 2>/dev/null || true +fi From 55799c4ab09f9cf9a60aed42b79f9ecf9f44bb2b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 13 Sep 2026 08:56:39 +0200 Subject: [PATCH 22/48] Poll the active power profile over D-Bus instead of powerprofilesctl The battery service ran `powerprofilesctl get` every two seconds to keep the active profile visible to the wallpaper and lock services. That command is a PyGObject script, so the shell spawned a Python interpreter for it tens of thousands of times a day. Roughly once a day one of those exits into a CPython 3.14 finalization race (python/cpython#124619): the GLib D-Bus worker thread calls PyGILState_Ensure after the interpreter is torn down and the process dies with SIGSEGV, leaving a core dump and a crash notification behind. Read the ActiveProfile property straight from power-profiles-daemon with busctl, the same way omarchy-powerprofiles-set already reads UPower. The output is JSON, so an empty or malformed reply when the daemon is not running still reads as no active profile, matching the previous behaviour. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011gbfh4Mi9dK6SAd1P2xMTi --- shell/plugins/services/battery/Service.qml | 26 +++++++++++++++++----- test/shell.d/video-background-test.sh | 3 ++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/shell/plugins/services/battery/Service.qml b/shell/plugins/services/battery/Service.qml index a1b01cff..eb7561f1 100644 --- a/shell/plugins/services/battery/Service.qml +++ b/shell/plugins/services/battery/Service.qml @@ -59,6 +59,16 @@ Item { if (!powerProfileReadProcess.running) powerProfileReadProcess.running = true } + function parseActiveProfile(text) { + // busctl --json=short prints {"type":"s","data":"balanced"}; an empty or + // malformed reply (daemon not running) reads as no active profile. + try { + return String(JSON.parse(text).data || "").trim() + } catch (e) { + return "" + } + } + Process { id: warningProcess } Process { @@ -71,17 +81,23 @@ Item { Process { id: powerProfileReadProcess - command: ["powerprofilesctl", "get"] + // Read the property straight from the daemon rather than via + // `powerprofilesctl get`. That is a PyGObject script, and spawning a Python + // interpreter for it every two seconds trips a CPython 3.14 shutdown race + // (python/cpython#124619): the GLib D-Bus worker thread re-enters the + // interpreter after finalization and the process dies with SIGSEGV, + // leaving a core dump and a crash notification behind roughly daily. + command: ["busctl", "--json=short", "get-property", "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles", "net.hadess.PowerProfiles", "ActiveProfile"] stdout: StdioCollector { waitForEnd: true - onStreamFinished: root.activePowerProfile = String(text || "").trim() + onStreamFinished: root.activePowerProfile = root.parseActiveProfile(text) } } Timer { - // powerprofilesctl has no portable monitor subcommand; keep profile changes - // visible to consumers such as the wallpaper service without requiring the - // power panel to be open. + // There is no portable way to subscribe to profile changes from QML; keep + // them visible to consumers such as the wallpaper service without requiring + // the power panel to be open. interval: 2000 running: true repeat: true diff --git a/test/shell.d/video-background-test.sh b/test/shell.d/video-background-test.sh index 1a15dace..7ebad86b 100755 --- a/test/shell.d/video-background-test.sh +++ b/test/shell.d/video-background-test.sh @@ -174,7 +174,8 @@ assert( assert( batteryService.includes('property string activePowerProfile') && batteryService.includes('UPower.onBattery && activePowerProfile === "power-saver"') && - batteryService.includes('["powerprofilesctl", "get"]') && + batteryService.includes('"get-property", "net.hadess.PowerProfiles", "/net/hadess/PowerProfiles", "net.hadess.PowerProfiles", "ActiveProfile"') && + !batteryService.includes('["powerprofilesctl"') && batteryService.includes('interval: 2000'), 'the battery service tracks the active power-saver profile' ) From 1b001a69d30bf89e4672a8fc15ea38c7359bff96 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 13 Sep 2026 00:56:44 -0700 Subject: [PATCH 23/48] Re-center the Plymouth prompt when a display appears late (#11641) The theme laid out the logo, password entry and progress bar once, when the script first ran. Plymouth sizes its window to the largest display present, so a monitor that comes up after plymouthd starts (a Thunderbolt display whose DisplayPort tunnel is established about a second after the kernel's display driver loads, a dock, an MST hub) got the prompt drawn at the internal panel's coordinates: off-center on the external display and partly off-screen on the panel. With the lid closed the passphrase box was effectively invisible. Move the layout into a function and re-run it from the refresh callback whenever Window.GetWidth/GetHeight change, which is how Plymouth's script plugin exposes a display being added or removed. Existing bullets are repositioned too. Co-authored-by: Claude Fable 5.1 --- default/plymouth/omarchy.script | 79 ++++++++++++++++++++++----------- 1 file changed, 53 insertions(+), 26 deletions(-) diff --git a/default/plymouth/omarchy.script b/default/plymouth/omarchy.script index 581514d7..0c9492bd 100644 --- a/default/plymouth/omarchy.script +++ b/default/plymouth/omarchy.script @@ -5,8 +5,6 @@ Window.SetBackgroundBottomColor(0.101, 0.105, 0.149); logo.image = Image("logo.png"); logo.sprite = Sprite(logo.image); -logo.sprite.SetX(Window.GetWidth() / 2 - logo.image.GetWidth() / 2); -logo.sprite.SetY(Window.GetHeight() / 2 - logo.image.GetHeight() / 2); logo.sprite.SetOpacity(1); # Use these to adjust the progress bar timing @@ -22,7 +20,18 @@ global.fake_progress_start_time = 0.0; # Track when fake progress started global.password_shown = 0; # Track if password dialog has been shown global.max_progress = 0.0; # Track the maximum progress reached to prevent backwards movement +# Window size the sprites were last laid out for +global.layout_width = 0; +global.layout_height = 0; + fun refresh_callback() { + # Displays can show up after this script ran, e.g. a Thunderbolt monitor + # whose DisplayPort tunnel comes up after plymouthd started. The window + # grows to the largest display, so re-center everything when it changes. + if (Window.GetWidth() != global.layout_width || Window.GetHeight() != global.layout_height) { + layout(); + } + global.animation_frame++; # Animate fake progress to limit over time with easing @@ -108,27 +117,23 @@ entry.image = Image("entry.png"); bullet.image = Image("bullet.png"); entry.sprite = Sprite(entry.image); -entry.x = Window.GetWidth() / 2 - entry.image.GetWidth() / 2; -entry.y = logo.sprite.GetY() + logo.image.GetHeight() + 40; -entry.sprite.SetPosition(entry.x, entry.y, 10001); entry.sprite.SetOpacity(0); # Scale lock to be slightly shorter than entry field height # Original lock is 84x96, entry height determines scale -lock_height = entry.image.GetHeight() * 0.8; -lock_scale = lock_height / 96; -lock_width = 84 * lock_scale; +lock.height = entry.image.GetHeight() * 0.8; +lock.width = 84 * (lock.height / 96); -scaled_lock = lock.image.Scale(lock_width, lock_height); -lock.sprite = Sprite(scaled_lock); -lock.x = entry.x - lock_width - 15; -lock.y = entry.y + entry.image.GetHeight() / 2 - lock_height / 2; -lock.sprite.SetPosition(lock.x, lock.y, 10001); +lock.sprite = Sprite(lock.image.Scale(lock.width, lock.height)); lock.sprite.SetOpacity(0); # Bullet array bullet.sprites = []; +fun position_bullet(index) { + bullet.sprites[index].SetPosition(entry.x + 20 + index * (7 + 5), entry.y + entry.image.GetHeight() / 2 - 3.5, 10002); +} + fun display_normal_callback() { hide_password_dialog(); @@ -165,11 +170,8 @@ fun display_password_callback(prompt, bullets) { for (index = 0; index < bullets_to_show; index++) { if (!bullet.sprites[index]) { # Scale bullet image to 7x7 pixels - scaled_bullet = bullet.image.Scale(7, 7); - bullet.sprites[index] = Sprite(scaled_bullet); - bullet.x = entry.x + 20 + index * (7 + 5); - bullet.y = entry.y + entry.image.GetHeight() / 2 - 3.5; - bullet.sprites[index].SetPosition(bullet.x, bullet.y, 10002); + bullet.sprites[index] = Sprite(bullet.image.Scale(7, 7)); + position_bullet(index); } bullet.sprites[index].SetOpacity(1); @@ -183,19 +185,11 @@ Plymouth.SetDisplayPasswordFunction(display_password_callback); progress_box.image = Image("progress_box.png"); progress_box.sprite = Sprite(progress_box.image); - -progress_box.x = Window.GetWidth() / 2 - progress_box.image.GetWidth() / 2; -progress_box.y = entry.y + entry.image.GetHeight() / 2 - progress_box.image.GetHeight() / 2; -progress_box.sprite.SetPosition(progress_box.x, progress_box.y, 0); progress_box.sprite.SetOpacity(0); progress_bar.original_image = Image("progress_bar.png"); progress_bar.sprite = Sprite(); progress_bar.image = progress_bar.original_image.Scale(1, progress_bar.original_image.GetHeight()); - -progress_bar.x = Window.GetWidth() / 2 - progress_bar.original_image.GetWidth() / 2; -progress_bar.y = progress_box.y + (progress_box.image.GetHeight() - progress_bar.original_image.GetHeight()) / 2; -progress_bar.sprite.SetPosition(progress_bar.x, progress_bar.y, 1); progress_bar.sprite.SetOpacity(0); fun progress_callback(duration, progress) { @@ -216,6 +210,39 @@ fun progress_callback(duration, progress) { Plymouth.SetBootProgressFunction(progress_callback); +#----------------------------------------- Layout -------------------------------- + +# Center the logo, dialogue and progress bar for the current window size +fun layout() { + global.layout_width = Window.GetWidth(); + global.layout_height = Window.GetHeight(); + + logo.sprite.SetX(global.layout_width / 2 - logo.image.GetWidth() / 2); + logo.sprite.SetY(global.layout_height / 2 - logo.image.GetHeight() / 2); + + entry.x = global.layout_width / 2 - entry.image.GetWidth() / 2; + entry.y = logo.sprite.GetY() + logo.image.GetHeight() + 40; + entry.sprite.SetPosition(entry.x, entry.y, 10001); + + lock.x = entry.x - lock.width - 15; + lock.y = entry.y + entry.image.GetHeight() / 2 - lock.height / 2; + lock.sprite.SetPosition(lock.x, lock.y, 10001); + + for (index = 0; bullet.sprites[index]; index++) { + position_bullet(index); + } + + progress_box.x = global.layout_width / 2 - progress_box.image.GetWidth() / 2; + progress_box.y = entry.y + entry.image.GetHeight() / 2 - progress_box.image.GetHeight() / 2; + progress_box.sprite.SetPosition(progress_box.x, progress_box.y, 0); + + progress_bar.x = global.layout_width / 2 - progress_bar.original_image.GetWidth() / 2; + progress_bar.y = progress_box.y + (progress_box.image.GetHeight() - progress_bar.original_image.GetHeight()) / 2; + progress_bar.sprite.SetPosition(progress_bar.x, progress_bar.y, 1); +} + +layout(); + #----------------------------------------- Message -------------------------------- message_sprite = Sprite(); From f5194e3ff596789b71b912ba945d90e3693cf6aa Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 13 Sep 2026 10:21:38 +0200 Subject: [PATCH 24/48] Shield Omarchy pacman transactions from desktop session teardown Upgrading systemd runs its post_upgrade scriptlet mid-transaction, which reexecs both the system manager and every user manager. When pacman runs inside a user-session scope (the floating update terminal), that reexec can SIGKILL it and abandon the transaction halfway, with packages upgraded but none of the post-transaction hooks run. Route every Omarchy-owned system mutation through a new hidden omarchy-update-pacman helper that registers the transaction as a PID 1 scope via systemd-run, keeping it out of the user manager's cgroups. System scopes survive the system manager's own reexec, and as a bonus the transaction now also survives its terminal window closing. On unbooted systems (the installer chroot) the helper runs pacman directly. Co-Authored-By: Claude Fable 5 --- bin/omarchy-channel-set | 2 +- bin/omarchy-refresh-pacman | 2 +- bin/omarchy-reinstall-pkgs | 4 ++-- bin/omarchy-update-pacman | 25 +++++++++++++++++++++++++ bin/omarchy-update-system-pkgs | 4 ++-- docs/update-process.md | 14 ++++++++++---- test/shell.d/channel-test.sh | 18 ++++++++++++------ 7 files changed, 53 insertions(+), 16 deletions(-) create mode 100755 bin/omarchy-update-pacman diff --git a/bin/omarchy-channel-set b/bin/omarchy-channel-set index 7e112388..dcc9bdb4 100755 --- a/bin/omarchy-channel-set +++ b/bin/omarchy-channel-set @@ -85,7 +85,7 @@ fi omarchy-refresh-pacman "$pacman_channel" # --ask 4 accepts omarchy <-> omarchy-dev replacement prompts without file overwrites. -sudo env OMARCHY_UPDATE_PACMAN=1 pacman -S --needed --noconfirm --ask 4 "${packages[@]}" +omarchy-update-pacman -S --needed --noconfirm --ask 4 "${packages[@]}" if [[ -z $dev_checkout ]]; then omarchy-dev-unlink --no-reboot diff --git a/bin/omarchy-refresh-pacman b/bin/omarchy-refresh-pacman index 299d6c20..af16a347 100755 --- a/bin/omarchy-refresh-pacman +++ b/bin/omarchy-refresh-pacman @@ -23,4 +23,4 @@ sudo cp -f "$OMARCHY_PATH/default/pacman/mirrorlist-$channel" /etc/pacman.d/mirr omarchy-hook pre-refresh-pacman # Reset all package DBs and then update -sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syyuu --noconfirm +omarchy-update-pacman -Syyuu --noconfirm diff --git a/bin/omarchy-reinstall-pkgs b/bin/omarchy-reinstall-pkgs index c1d51cbf..97aff9c6 100755 --- a/bin/omarchy-reinstall-pkgs +++ b/bin/omarchy-reinstall-pkgs @@ -11,8 +11,8 @@ set -e omarchy-refresh-pacman # Downgrade any packages to the stable setup -sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Suu --noconfirm +omarchy-update-pacman -Suu --noconfirm # Ensure all packages are installed mapfile -t packages < <(grep -v '^#' "$OMARCHY_PATH/install/omarchy-base.packages" | grep -v '^$') -sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm --needed "${packages[@]}" +omarchy-update-pacman -Syu --noconfirm --needed "${packages[@]}" diff --git a/bin/omarchy-update-pacman b/bin/omarchy-update-pacman new file mode 100755 index 00000000..66f66731 --- /dev/null +++ b/bin/omarchy-update-pacman @@ -0,0 +1,25 @@ +#!/bin/bash + +# omarchy:summary=Run a pacman transaction for the Omarchy update flow, shielded from desktop session teardown. +# omarchy:args= +# omarchy:hidden=true +# omarchy:requires-sudo=true + +# Upgrading systemd runs its post_upgrade scriptlet mid-transaction, which +# reexecs both the system manager and every user manager. A pacman running +# inside a user-session scope can be SIGKILLed by that reexec, abandoning the +# transaction halfway. Registering the transaction as a PID 1 scope keeps it +# out of the user manager's cgroups entirely, and system scopes survive the +# system manager's own reexec. +scope=() +if [[ -d /run/systemd/system && ! -L /run/systemd/system ]]; then + scope=(systemd-run --scope --quiet --collect) +fi + +# LC_ALL passes through so callers that parse pacman's stderr can pin the locale. +env_args=(OMARCHY_UPDATE_PACMAN=1) +if [[ -n ${LC_ALL:-} ]]; then + env_args+=(LC_ALL="$LC_ALL") +fi + +exec sudo env "${env_args[@]}" "${scope[@]}" pacman "$@" diff --git a/bin/omarchy-update-system-pkgs b/bin/omarchy-update-system-pkgs index 9e6f9998..c2e86388 100755 --- a/bin/omarchy-update-system-pkgs +++ b/bin/omarchy-update-system-pkgs @@ -10,7 +10,7 @@ set -e # a stream, because an upgrade without --noconfirm prompts on stderr. The # handler has already said why, so no heading here either. if [[ ${OMARCHY_UPDATE_CONFLICT:-} == 1 && ${OMARCHY_UPDATE_INTERACTIVE:-} == 1 ]]; then - exec sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --overwrite '/usr/share/omarchy/*' + exec omarchy-update-pacman -Syu --overwrite '/usr/share/omarchy/*' fi echo -e "\e[32m\nUpdate system packages\e[0m" @@ -23,7 +23,7 @@ trap 'rm -f "$errors"' EXIT # # Progress bars stay on stdout. Errors are on stderr, kept for the conflict # handler below; LC_ALL=C is what keeps them parseable in any locale. -if sudo env LC_ALL=C OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm \ +if LC_ALL=C omarchy-update-pacman -Syu --noconfirm \ --overwrite '/usr/share/omarchy/*' 2>"$errors"; then cat "$errors" >&2 exit 0 diff --git a/docs/update-process.md b/docs/update-process.md index bec3350d..efecb94c 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -82,13 +82,18 @@ Omarchy update command, the hook exits non-zero with `AbortOnFail`, which stops the transaction before packages are changed. `omarchy-update-system-pkgs`, `omarchy-refresh-pacman`, `omarchy-reinstall-pkgs`, -`omarchy-channel-set`, and the v4 upgrader run pacman through: +and `omarchy-channel-set` run pacman through the hidden `omarchy-update-pacman` +helper (the v4 upgrader sets `OMARCHY_UPDATE_PACMAN=1` directly): ```bash -env OMARCHY_UPDATE_PACMAN=1 pacman ... +sudo env OMARCHY_UPDATE_PACMAN=1 systemd-run --scope --quiet --collect pacman ... ``` -so the guard allows Omarchy-owned update flows. A user can intentionally bypass +so the guard allows Omarchy-owned update flows. The `systemd-run --scope` +wrapper registers the transaction as a PID 1 scope: upgrading systemd reexecs +the system and user managers mid-transaction, and a pacman left inside a +user-session scope can be SIGKILLed by that reexec. On unbooted systems (such +as the installer chroot) the helper runs pacman directly. A user can intentionally bypass the guard with: ```bash @@ -272,12 +277,13 @@ scripts. | `omarchy-update-confirm` | Gum confirmation copy for `omarchy update`. | **Question.** Could be inlined into `omarchy-update`; separate file only helps keep copy isolated. | | `omarchy-update-dev` | Fast-forwards the active dev-linked checkout from its configured upstream; no-ops for package-backed installs. | **Keep.** Runs before package updates so a checkout conflict stops the update before system mutation. | | `omarchy-update-keyring` | Ensures Omarchy keyring and Arch keyring are current before the main transaction. | **Keep, but review.** It uses targeted `pacman -Sy` for keyring bootstrapping; acceptable for this special case but should remain tightly scoped. | -| `omarchy-update-system-pkgs` | Runs `sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm` with `--overwrite '/usr/share/omarchy/*'`, capturing stderr to a report file; on failure it execs `omarchy-update-system-pkgs-when-conflicted`. | **Keep for now.** Small leaf command, clear/testable. | +| `omarchy-update-system-pkgs` | Runs `omarchy-update-pacman -Syu --noconfirm` with `--overwrite '/usr/share/omarchy/*'`, capturing stderr to a report file; on failure it execs `omarchy-update-system-pkgs-when-conflicted`. | **Keep for now.** Small leaf command, clear/testable. | | `omarchy-update-system-pkgs-when-conflicted` | Hidden conflict handler: quarantines unowned conflicting files under `/var/lib/omarchy/replaced`, retries the upgrade once, restores files the upgrade didn't claim, and hands package-vs-package conflicts to an interactive pacman run (never under `-y`). | **Keep internal/hidden.** Keeps conflict recovery out of the happy path. | | `omarchy-update-pkg-prune` | Trims the pacman cache to two versions per package (`paccache -rk2`) before the snapshot, keeping the offline downgrade path while capping snapshot growth. | **Keep internal/hidden.** | | `omarchy-update-requires-free-space` | Aborts the update below a 10 GiB free-space threshold on `/`; silently skipped when free space cannot be determined; `OMARCHY_UPDATE_FORCE=1` bypasses. | **Keep internal/hidden.** | | `omarchy-migrate` | Public migration command. Waits for pacman, then runs all pending migrations for the current user. Supports `--pending`. | **Keep.** This replaces the discarded `omarchy-update-user-finalize` name and no longer needs `--force`. | | `omarchy-update-pacman-guard` | ALPM pre-transaction guard that aborts direct `pacman -Syu` style upgrades unless Omarchy set `OMARCHY_UPDATE_PACMAN=1` or the user explicitly set `OMARCHY_ALLOW_DIRECT_PACMAN=1`. | **Keep internal/hidden.** This is what nudges users back to `omarchy update`. | +| `omarchy-update-pacman` | Hidden helper that runs a guard-approved pacman transaction as a PID 1 scope (`systemd-run --scope`) so a mid-transaction systemd reexec cannot kill it; runs pacman directly when not booted under systemd. | **Keep internal/hidden.** Single place that owns how Omarchy invokes pacman for system mutation. | | `omarchy-migrate-notify` | Internal login-time notification helper. Uses `omarchy-migrate --pending` and shows a notification only when this user has pending migrations. | **Keep internal/hidden.** Clear name now that the public command is `omarchy-migrate`. | | `omarchy-update-user-notify` | Hidden compatibility wrapper for `omarchy-migrate-notify`. | **Temporary.** Keep only for old callers. | | `omarchy-update-available` | Update checker for shell widget and post-update refresh. | **Keep.** Could eventually be renamed `omarchy-update-check`, but current name matches widget semantics. | diff --git a/test/shell.d/channel-test.sh b/test/shell.d/channel-test.sh index 664e17c5..122a15aa 100644 --- a/test/shell.d/channel-test.sh +++ b/test/shell.d/channel-test.sh @@ -31,6 +31,12 @@ for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG" ' +write_stub omarchy-update-pacman '#!/bin/bash +printf "update-pacman" >>"$OMARCHY_CHANNEL_TEST_LOG" +for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done +printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG" +' + write_stub omarchy-dev-unlink '#!/bin/bash printf "unlink" >>"$OMARCHY_CHANNEL_TEST_LOG" for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done @@ -106,7 +112,7 @@ assert_log_line() { run_channel stable assert_log_line $'refresh\tstable' "stable refreshes the stable pacman channel" -assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "stable installs stable Omarchy packages" +assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "stable installs stable Omarchy packages" assert_log_line $'unlink\t--no-reboot' "stable restores the package-backed Omarchy path without an early reboot prompt" assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "stable runs the normal update pipeline from the package-backed path" if grep -q $'^state\tset\treboot-required$' "$log_file"; then @@ -116,17 +122,17 @@ pass "stable does not require reboot when already package-backed" run_channel rc assert_log_line $'refresh\trc' "rc refreshes the rc pacman channel" -assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "rc installs rc Omarchy packages" +assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "rc installs rc Omarchy packages" assert_log_line $'unlink\t--no-reboot' "rc restores the package-backed Omarchy path without an early reboot prompt" assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "rc runs the normal update pipeline from the package-backed path" OMARCHY_TEST_PATH="$ROOT" run_channel edge assert_log_line $'refresh\tedge' "edge refreshes the edge pacman channel" -assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "edge installs development Omarchy packages" +assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "edge installs development Omarchy packages" assert_log_line $'unlink\t--no-reboot' "edge unlinks dev without an early reboot prompt" assert_log_line $'state\tset\treboot-required' "edge marks reboot required when leaving dev" assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "edge runs the normal update pipeline from the package-backed path" -[[ $(grep -E '^(unlink|state|update)' "$log_file") == $'unlink\t--no-reboot\nstate\tset\treboot-required\nupdate\t-y\tOMARCHY_PATH=/usr/share/omarchy' ]] || +[[ $(grep -E $'^(unlink|state|update)\t' "$log_file") == $'unlink\t--no-reboot\nstate\tset\treboot-required\nupdate\t-y\tOMARCHY_PATH=/usr/share/omarchy' ]] || fail "edge defers the reboot prompt until the update restart stage" "$(cat "$log_file")" pass "edge defers the reboot prompt until the update restart stage" @@ -146,12 +152,12 @@ rmdir "$checkout" run_channel dev assert_log_line $'gum\tconfirm\t--default=false\tSwitch to dev channel?' "dev asks for confirmation" assert_log_line $'refresh\tedge' "dev refreshes the edge pacman channel" -assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "dev installs development Omarchy packages" +assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "dev installs development Omarchy packages" assert_log_line $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout" "dev clones the source checkout to ~/omarchy" assert_log_line $'link\t'"$checkout"$'\t--no-reboot' "dev links ~/omarchy without an early reboot prompt" assert_log_line $'state\tset\treboot-required' "dev defers the reboot prompt to the update pipeline" assert_log_line $'update\t-y\tOMARCHY_PATH='"$checkout" "dev runs the normal update pipeline from the source checkout" -[[ $(grep -E '^(git|link|state|refresh|sudo|update)' "$log_file") == $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout"$'\nlink\t'"$checkout"$'\t--no-reboot\nstate\tset\treboot-required\nrefresh\tedge\nsudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev\nupdate\t-y\tOMARCHY_PATH='"$checkout" ]] || +[[ $(grep -E '^(git|link|state|refresh|sudo|update)' "$log_file") == $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout"$'\nlink\t'"$checkout"$'\t--no-reboot\nstate\tset\treboot-required\nrefresh\tedge\nupdate-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev\nupdate\t-y\tOMARCHY_PATH='"$checkout" ]] || fail "dev activates the checkout before changing or updating packages" "$(cat "$log_file")" pass "dev activates the checkout before changing or updating packages" From bc0753dfcbf723a362060c32b3e36739798fee38 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 13 Sep 2026 10:21:46 +0200 Subject: [PATCH 25/48] Show a failure state when a presented command exits non-zero The floating-terminal presentation wrapper showed the green "Done!" prompt for every exit code except Ctrl-C, so a failed update or channel switch closed looking like a success. Pass the command's exit code through to omarchy-show-done and render a red "Failed (exit code N)!" prompt when it is non-zero. The pkg install/remove pickers get the same treatment. Co-Authored-By: Claude Fable 5 --- ...omarchy-launch-floating-terminal-with-presentation | 3 ++- bin/omarchy-pkg-aur-install | 3 ++- bin/omarchy-pkg-install | 2 +- bin/omarchy-pkg-remove | 2 +- bin/omarchy-show-done | 11 +++++++++-- 5 files changed, 15 insertions(+), 6 deletions(-) diff --git a/bin/omarchy-launch-floating-terminal-with-presentation b/bin/omarchy-launch-floating-terminal-with-presentation index c9cb255a..4497ff0d 100755 --- a/bin/omarchy-launch-floating-terminal-with-presentation +++ b/bin/omarchy-launch-floating-terminal-with-presentation @@ -8,6 +8,7 @@ source omarchy-restart-gum cmd="$*" -presentation_script="omarchy-show-logo; $cmd; if (( \$? != 130 )); then omarchy-show-done; fi" +# 130 is the user bailing with Ctrl-C; anything else gets the Done/Failed prompt. +presentation_script="omarchy-show-logo; $cmd; code=\$?; if (( code != 130 )); then omarchy-show-done \$code; fi" exec setsid uwsm-app -- xdg-terminal-exec --app-id=org.omarchy.terminal --title=Omarchy -e bash -c "$presentation_script" diff --git a/bin/omarchy-pkg-aur-install b/bin/omarchy-pkg-aur-install index 3d38b4db..dc393b8d 100755 --- a/bin/omarchy-pkg-aur-install +++ b/bin/omarchy-pkg-aur-install @@ -24,6 +24,7 @@ if [[ -n $pkg_names ]]; then source omarchy-sudo-keepalive echo "$pkg_names" | sed 's/^/aur\//' | tr '\n' ' ' | xargs yay -S --noconfirm + code=$? sudo updatedb --prune-bind-mounts=no --add-prunepaths=/.snapshots - omarchy-show-done + omarchy-show-done $code fi diff --git a/bin/omarchy-pkg-install b/bin/omarchy-pkg-install index 15e5e288..e5e0fb42 100755 --- a/bin/omarchy-pkg-install +++ b/bin/omarchy-pkg-install @@ -22,5 +22,5 @@ if [[ -n $pkg_names ]]; then # Convert newline-separated selections to space-separated for pacman echo "$pkg_names" | tr '\n' ' ' | xargs sudo pacman -S --noconfirm - omarchy-show-done + omarchy-show-done $? fi diff --git a/bin/omarchy-pkg-remove b/bin/omarchy-pkg-remove index 486d3772..37ca448e 100755 --- a/bin/omarchy-pkg-remove +++ b/bin/omarchy-pkg-remove @@ -20,5 +20,5 @@ pkg_names=$(yay -Qqe | fzf "${fzf_args[@]}") if [[ -n $pkg_names ]]; then # Convert newline-separated selections to space-separated for yay echo "$pkg_names" | tr '\n' ' ' | xargs sudo pacman -Rns --noconfirm - omarchy-show-done + omarchy-show-done $? fi diff --git a/bin/omarchy-show-done b/bin/omarchy-show-done index d5d8ccde..14c9f156 100755 --- a/bin/omarchy-show-done +++ b/bin/omarchy-show-done @@ -1,6 +1,9 @@ #!/bin/bash -# omarchy:summary=Display a "Done!" message and wait for user to press any key. +# omarchy:summary=Display a "Done!" or "Failed!" message and wait for user to press any key. +# omarchy:args=[exit-code] + +code="${1:-0}" # The device node is there whether or not a terminal is behind it, so opening # it is the only test that means anything. @@ -13,6 +16,10 @@ while read -rsn 1 -t 0.1 _ /dev/tty +if (( code == 0 )); then + printf '\n\033[32m● \033[0mDone! Press any key to close...' >/dev/tty +else + printf '\n\033[31m● \033[0mFailed (exit code %d)! Press any key to close...' "$code" >/dev/tty +fi read -rsn 1 /dev/tty From 24841ac5d24552ebb7bfe4e0491949236e22963a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 13 Sep 2026 10:21:46 +0200 Subject: [PATCH 26/48] Tell how to resume a channel switch that failed partway omarchy-channel-set runs under set -e, so a failure after it has begun mutating the system (dev link, pacman channel, packages) died silently with the switch half-applied. Trap ERR once the mutation phase starts and say how to pick the switch back up. Co-Authored-By: Claude Fable 5 --- bin/omarchy-channel-set | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bin/omarchy-channel-set b/bin/omarchy-channel-set index dcc9bdb4..d4c482f2 100755 --- a/bin/omarchy-channel-set +++ b/bin/omarchy-channel-set @@ -72,6 +72,10 @@ case "$channel" in ;; esac +# A failure past this point leaves the channel switch half-applied, so say how +# to pick it back up rather than dying silently under set -e. +trap 'echo -e "\nThe channel switch did not complete. Review the error above, then rerun: omarchy-channel-set '"$channel"'" >&2' ERR + if [[ -z $dev_checkout && $OMARCHY_PATH != "/usr/share/omarchy" ]]; then leaving_dev=1 fi From 83c6dd230c1e9ec35550c6ad077a206b17a99b8b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 13 Sep 2026 10:35:32 +0200 Subject: [PATCH 27/48] Keep the conflict tests inside their fixture despite the scope wrapper The update conflict tests stub sudo and pacman, but omarchy-update-pacman now puts systemd-run between them, so on a systemd-booted host the tests would reach for the real system manager. Stub systemd-run to drop the wrapper's options and run the command, and cover the helper's own invocation composition in a new test. Raised by codex review. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015zcqENR1UbhuwC1v5u3Wop --- test/shell.d/update-file-conflict-test.sh | 10 ++++- test/shell.d/update-package-conflict-test.sh | 10 ++++- test/shell.d/update-pacman-test.sh | 39 ++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100755 test/shell.d/update-pacman-test.sh diff --git a/test/shell.d/update-file-conflict-test.sh b/test/shell.d/update-file-conflict-test.sh index 5fa954ea..526a1c6c 100755 --- a/test/shell.d/update-file-conflict-test.sh +++ b/test/shell.d/update-file-conflict-test.sh @@ -15,6 +15,14 @@ cat >"$stub_bin/sudo" <<'STUB' exec "$@" STUB +# omarchy-update-pacman wraps the transaction in a real PID 1 scope; the tests +# must stay inside the fixture, so drop the wrapper's options and run the command. +cat >"$stub_bin/systemd-run" <<'STUB' +#!/bin/bash +while [[ $1 == -* ]]; do shift; done +exec "$@" +STUB + # Fails the first -Syu with the report under test, then succeeds unless the case # asked for the retry to fail too. cat >"$stub_bin/pacman" <<'STUB' @@ -40,7 +48,7 @@ fi echo "upgrade complete" STUB -chmod +x "$stub_bin/sudo" "$stub_bin/pacman" +chmod +x "$stub_bin/sudo" "$stub_bin/systemd-run" "$stub_bin/pacman" replaced="$test_tmp/replaced" diff --git a/test/shell.d/update-package-conflict-test.sh b/test/shell.d/update-package-conflict-test.sh index cbe6286f..e05e8f7b 100755 --- a/test/shell.d/update-package-conflict-test.sh +++ b/test/shell.d/update-package-conflict-test.sh @@ -17,6 +17,14 @@ cat >"$stub_bin/sudo" <<'STUB' exec "$@" STUB +# omarchy-update-pacman wraps the transaction in a real PID 1 scope; the tests +# must stay inside the fixture, so drop the wrapper's options and run the command. +cat >"$stub_bin/systemd-run" <<'STUB' +#!/bin/bash +while [[ $1 == -* ]]; do shift; done +exec "$@" +STUB + # Fails the first -Syu with the report under test, then succeeds. Every call # records its arguments and which of its streams reached a terminal: pacman puts # its questions on stderr once it is not running --noconfirm, so a retry meant @@ -39,7 +47,7 @@ fi echo "upgrade complete" STUB -chmod +x "$stub_bin/sudo" "$stub_bin/pacman" +chmod +x "$stub_bin/sudo" "$stub_bin/systemd-run" "$stub_bin/pacman" # Everything a blocked qemu-common upgrade leaves on stderr, and no more. The # ":: ... Remove qemu-block-gluster? [y/N]" pacman asked is deliberately absent: diff --git a/test/shell.d/update-pacman-test.sh b/test/shell.d/update-pacman-test.sh new file mode 100755 index 00000000..a9278b37 --- /dev/null +++ b/test/shell.d/update-pacman-test.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +mkdir -p "$stub_bin" + +cat >"$stub_bin/sudo" <<'STUB' +#!/bin/bash +printf '%s\n' "$*" >"$SUDO_CALL_LOG" +STUB +chmod +x "$stub_bin/sudo" + +run_helper() { + PATH="$stub_bin:$PATH" SUDO_CALL_LOG="$test_tmp/call" "$ROOT/bin/omarchy-update-pacman" "$@" +} + +# The scope wrapper only applies on a systemd-booted host, so expect what the +# helper's own booted check would decide for this machine. +if [[ -d /run/systemd/system && ! -L /run/systemd/system ]]; then + expected_scope="systemd-run --scope --quiet --collect " +else + expected_scope="" +fi + +run_helper -Syu --noconfirm +[[ $(cat "$test_tmp/call") == "env OMARCHY_UPDATE_PACMAN=1 ${expected_scope}pacman -Syu --noconfirm" ]] || + fail "helper composes the guarded pacman invocation" "$(cat "$test_tmp/call")" +pass "helper composes the guarded pacman invocation" + +LC_ALL=C run_helper -Syu +[[ $(cat "$test_tmp/call") == "env OMARCHY_UPDATE_PACMAN=1 LC_ALL=C ${expected_scope}pacman -Syu" ]] || + fail "helper forwards LC_ALL to the transaction" "$(cat "$test_tmp/call")" +pass "helper forwards LC_ALL to the transaction" From dae1f4bf0e3f0a4c1e1a01b423e7b0f5f442d17a Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 13 Sep 2026 11:53:27 +0200 Subject: [PATCH 28/48] Use the kyber I/O scheduler on real disks The kernel leaves NVMe on none and everything else on mq-deadline. Neither bounds latency once the device queue fills, so a large build, copy, or package upgrade can make the desktop sluggish while reads wait behind a wall of writes. Kyber keeps separate read and sync-write queues and throttles the depth it submits to hit a 2ms read target, which keeps interactive reads flowing under heavy writes at negligible CPU cost. The trade is a small ceiling on peak throughput on very fast devices, which matters for a storage server chasing IOPS but not for a desktop. Ships as a package-owned udev rule in /etc so it applies at boot and on hot-plug. Existing installs pick it up at the next boot. zram is left alone since it has nothing to schedule. Co-Authored-By: Claude Fable 5.1 --- etc/udev/rules.d/60-omarchy-io-scheduler.rules | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 etc/udev/rules.d/60-omarchy-io-scheduler.rules diff --git a/etc/udev/rules.d/60-omarchy-io-scheduler.rules b/etc/udev/rules.d/60-omarchy-io-scheduler.rules new file mode 100644 index 00000000..7f83f6ce --- /dev/null +++ b/etc/udev/rules.d/60-omarchy-io-scheduler.rules @@ -0,0 +1,6 @@ +# Kyber targets read latency (2ms) and throttles writes to hold it, so the +# desktop stays responsive under a large build, copy, or package upgrade. +# The kernel default is none for NVMe and mq-deadline for everything else, +# neither of which bounds latency once the device queue fills. Applies to real +# disks only; zram is memory and has nothing to schedule. +ACTION=="add|change", KERNEL=="nvme[0-9]*n[0-9]*|sd[a-z]*|mmcblk[0-9]*|vd[a-z]*", ATTR{queue/scheduler}="kyber" From 795ac57a6c65ed1943c62739efb228bffce048bd Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 13 Sep 2026 12:09:50 +0200 Subject: [PATCH 29/48] Match only whole disks in the kyber udev rule udev patterns are shell globs, so nvme[0-9]*n[0-9]* also matched every partition. Partitions have no queue/scheduler, and udev logged a "Could not chase sysfs attribute" for each one at boot. Restrict the match to SUBSYSTEM block with DEVTYPE disk, which also keeps mmcblk boot areas and NVMe multipath nodes out. Reword the comment: kyber targets a read latency rather than bounding it, and the kernel's default choice depends on the device rather than being a fixed NVMe versus everything-else split. Co-Authored-By: Claude Fable 5.1 --- etc/udev/rules.d/60-omarchy-io-scheduler.rules | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/etc/udev/rules.d/60-omarchy-io-scheduler.rules b/etc/udev/rules.d/60-omarchy-io-scheduler.rules index 7f83f6ce..1f389f4b 100644 --- a/etc/udev/rules.d/60-omarchy-io-scheduler.rules +++ b/etc/udev/rules.d/60-omarchy-io-scheduler.rules @@ -1,6 +1,8 @@ -# Kyber targets read latency (2ms) and throttles writes to hold it, so the -# desktop stays responsive under a large build, copy, or package upgrade. -# The kernel default is none for NVMe and mq-deadline for everything else, -# neither of which bounds latency once the device queue fills. Applies to real -# disks only; zram is memory and has nothing to schedule. -ACTION=="add|change", KERNEL=="nvme[0-9]*n[0-9]*|sd[a-z]*|mmcblk[0-9]*|vd[a-z]*", ATTR{queue/scheduler}="kyber" +# Kyber keeps reads in their own queue and throttles the depth it submits to +# hold a 2ms read latency target, so interactive reads keep flowing while a +# large build, copy, or package upgrade floods the disk with writes. The +# kernel's own pick (none or mq-deadline, depending on the device) does not +# regulate latency once the queue fills. Whole disks only: partitions have no +# scheduler of their own, and zram is memory with nothing to schedule. Zoned +# btrfs disks are moved back to mq-deadline by 64-btrfs-zoned.rules. +ACTION=="add|change", SUBSYSTEM=="block", ENV{DEVTYPE}=="disk", KERNEL=="nvme*|sd*|mmcblk*|vd*", ATTR{queue/scheduler}="kyber" From 9246647071329fed57066cfd8acb34438d01df5d Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 13 Sep 2026 12:15:27 +0200 Subject: [PATCH 30/48] Switch TCP congestion control to BBR with fq pacing Cubic keeps pushing until packets drop, which stands queues up in the path on fast links. BBR paces to its estimate of bottleneck bandwidth and minimum RTT instead, cutting queueing latency while keeping throughput. fq is the qdisc BBR is built to pace through. tcp_bbr and sch_fq are modules in every kernel Omarchy ships and autoload when the sysctls are set. The migration re-applies the shipped file so new connections switch without a reboot, no-ops once the live values match, and flags a reboot if applying fails. Co-Authored-By: Claude Fable 5.1 --- etc/sysctl.d/99-omarchy-sysctl.conf | 6 ++++++ migrations/1789294350.sh | 11 +++++++++++ 2 files changed, 17 insertions(+) create mode 100644 migrations/1789294350.sh diff --git a/etc/sysctl.d/99-omarchy-sysctl.conf b/etc/sysctl.d/99-omarchy-sysctl.conf index 1cce6d7f..dac0fb2b 100644 --- a/etc/sysctl.d/99-omarchy-sysctl.conf +++ b/etc/sysctl.d/99-omarchy-sysctl.conf @@ -1,6 +1,12 @@ # Solve common flakiness with SSH (MTU discovery on flaky links). net.ipv4.tcp_mtu_probing=1 +# BBR estimates bottleneck bandwidth and minimum RTT and paces to them, where +# cubic keeps pushing until packets drop. That cuts queueing latency +# (bufferbloat) on fast links. fq is the qdisc BBR is built to pace through. +net.core.default_qdisc=fq +net.ipv4.tcp_congestion_control=bbr + # Tune reclaim for swap on zram, which is orders of magnitude faster than the # disk swapfile these defaults assume. diff --git a/migrations/1789294350.sh b/migrations/1789294350.sh new file mode 100644 index 00000000..9eba0416 --- /dev/null +++ b/migrations/1789294350.sh @@ -0,0 +1,11 @@ +echo "Switch TCP congestion control to BBR with fq pacing" + +# Boot applies the shipped file regardless; this only makes new connections +# use BBR now. Setting the sysctls autoloads tcp_bbr and sch_fq. Qdiscs +# already attached to interfaces stay until they are recreated, normally at +# reboot. +if [[ $(sysctl -n net.ipv4.tcp_congestion_control) == "bbr" && $(sysctl -n net.core.default_qdisc) == "fq" ]]; then + exit 0 +fi + +sudo sysctl -p /etc/sysctl.d/99-omarchy-sysctl.conf >/dev/null || omarchy-state set reboot-required From d377569edb7890270e98b4e83b94189716dc7ab7 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 13 Sep 2026 12:37:08 +0200 Subject: [PATCH 31/48] Count only tiled windows and refit when apps move or float A floating window on the console is not laid out by the gaps, so it no longer stretches the panel to full width. Moving an app onto or off the scratchpad and toggling floating now refit too, via window.move_to_workspace and window.update_rules; both were measured on Hyprland 0.56.2 to carry the settled count. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012UyVoFTM98Tduoxg7qZax7 --- default/hypr/qconsole.lua | 28 ++++++++++---- test/shell.d/hyprland-qconsole-test.sh | 53 +++++++++++++++++++++++--- 2 files changed, 68 insertions(+), 13 deletions(-) diff --git a/default/hypr/qconsole.lua b/default/hypr/qconsole.lua index cb248f4c..a512f8ae 100644 --- a/default/hypr/qconsole.lua +++ b/default/hypr/qconsole.lua @@ -63,10 +63,17 @@ local function cover(side, bottom) end -- One window reads as a console and gets the panel. A second app has turned the --- scratchpad into a workspace, and a workspace wants the whole width. +-- scratchpad into a workspace, and a workspace wants the whole width. Only tiled +-- windows count: the gaps are what size the panel, and a floating window on top +-- of the console is not laid out by them. local function alone() - local ws = hl.get_workspace(SCRATCHPAD) - return not ws or ws.windows <= 1 + local tiled = 0 + for _, window in ipairs(hl.get_workspace_windows(SCRATCHPAD)) do + if not window.floating then + tiled = tiled + 1 + end + end + return tiled <= 1 end -- Sizing the console with a window rule would freeze it at whatever the screen @@ -158,11 +165,14 @@ hl.on("workspace.move_to_monitor", function(ws, mon) end end) --- The panel is only centered while the console holds one window, so the count --- has to be rechecked as apps come and go. These are the two events that run --- after the workspace's count has already moved: window.close and --- window.move_to_workspace still count the window on its way out, and refitting --- from those would read one too many and leave the console full width. +-- The panel is only centered while the console holds one tiled window, so the +-- count has to be rechecked as apps come and go: opened and closed, moved on or +-- off (Super+Alt+S, Super+Shift+1), and floated or tiled (Super+T, Super+O). +-- window.close is left out, since it still counts the window on its way out and +-- window.destroy follows it anyway. Measured on Hyprland 0.56.2, a move is +-- trailed by several window.update_rules, as is a float toggle, and the last of +-- those always reads the settled count; the earlier ones refit to what the rule +-- already is, which cover() skips. -- -- Only while it is on screen, though. A hidden console is refitted on its way in -- by workspace.special_active, and every window opened anywhere on the desktop @@ -176,6 +186,8 @@ end hl.on("window.open", recount) hl.on("window.destroy", recount) +hl.on("window.move_to_workspace", recount) +hl.on("window.update_rules", recount) -- The direction names the edge the offset is measured from, not where the -- workspace goes: "slide top" drops it down into view, and "slide bottom" diff --git a/test/shell.d/hyprland-qconsole-test.sh b/test/shell.d/hyprland-qconsole-test.sh index c5123d03..57feb176 100755 --- a/test/shell.d/hyprland-qconsole-test.sh +++ b/test/shell.d/hyprland-qconsole-test.sh @@ -22,6 +22,14 @@ hl = { on = function(event, callback) handlers[event] = callback end, get_active_monitor = function() return monitor end, get_workspace = function() return workspace end, + -- workspace.windows is the tiled count here and workspace.floats the floating + -- one, so the fixtures can say which kind of window is on the console. + get_workspace_windows = function() + local list = {} + for _ = 1, workspace and workspace.windows or 0 do table.insert(list, { floating = false }) end + for _ = 1, workspace and workspace.floats or 0 do table.insert(list, { floating = true }) end + return list + end, exec_scheduled_prop_refresh_immediately = function() end, } @@ -226,11 +234,46 @@ handlers["monitor.layout_changed"]() top, right, bottom, left = gaps() assert(left == 420 and right == 420 and bottom == 540, "a scratchpad that does not exist yet is sized as a console") --- window.close and window.move_to_workspace both run while the workspace still --- counts the window that is leaving, so a refit from either reads one too many --- and strands the console at full width. window.open and window.destroy are the --- two that run after the count has already moved, and are the only ones hooked. +-- Back to a console on screen for the move and float checks. +workspace = { name = "special:scratchpad", visible = true, monitor = acer, windows = 1 } +monitor = acer +handlers["monitor.layout_changed"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435, "a single tiled window is a console") + +-- A floating window on top of the console is not laid out by the gaps, so it +-- must not stretch the panel out from under the agent. +workspace.floats = 1 +handlers["window.open"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435, "a floating window on the console keeps the panel") + +-- Tiling that float (Super+T) makes it a second app, and floating it again +-- gives the panel back. Both arrive as window.update_rules. +workspace.floats, workspace.windows = 0, 2 +handlers["window.update_rules"]() +top, right, bottom, left = gaps() +assert(left == 0 and right == 0, "tiling a float on the console restores the full width") + +workspace.floats, workspace.windows = 1, 1 +handlers["window.update_rules"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435, "floating it again recenters the panel") + +-- Sending an app onto the scratchpad (Super+Alt+S) or off it (Super+Shift+1) +-- does not open or destroy anything, so the move itself has to refit. +workspace.floats, workspace.windows = 0, 2 +handlers["window.move_to_workspace"]() +top, right, bottom, left = gaps() +assert(left == 0 and right == 0, "moving a second app onto the console restores the full width") + +workspace.windows = 1 +handlers["window.move_to_workspace"]() +top, right, bottom, left = gaps() +assert(left == 435 and right == 435, "moving it back off recenters the panel") + +-- window.close still counts the window on its way out, and window.destroy +-- follows it with the settled count, so only destroy is hooked. assert(handlers["window.close"] == nil, "window.close counts the window on its way out") -assert(handlers["window.move_to_workspace"] == nil, "window.move_to_workspace does too") LUA pass "the console is a centered panel until a second app joins it" From 69c67c63d69db8e39ce3b32ac879b757925ea9c5 Mon Sep 17 00:00:00 2001 From: Anton Kesy Date: Sun, 13 Sep 2026 16:28:44 +0200 Subject: [PATCH 32/48] Fix typo in manual --- manual/46-faq.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manual/46-faq.md b/manual/46-faq.md index 6c625407..21b11db9 100644 --- a/manual/46-faq.md +++ b/manual/46-faq.md @@ -62,7 +62,7 @@ export OMARCHY_SCREENSHOT_DIR="$HOME/Pictures/Screenshots" You can do the same for screenrecordings using `OMARCHY_SCREENRECORD_DIR`. -Just remember to create the directoy you want to save to and restart Omarchy for this to take effect. +Just remember to create the directory you want to save to and restart Omarchy for this to take effect. ### How do I get the speakers + webcam working on my Apple Studio Display? From a389bd1852c96a6f0f8a5572b3435efe042ba28e Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 13 Sep 2026 16:59:33 +0200 Subject: [PATCH 33/48] Add Cloudflare CLI lazy wrapper --- install/user/mise.sh | 1 + migrations/1789310715.sh | 5 +++++ 2 files changed, 6 insertions(+) create mode 100644 migrations/1789310715.sh diff --git a/install/user/mise.sh b/install/user/mise.sh index 81e0364c..dfc56aa2 100644 --- a/install/user/mise.sh +++ b/install/user/mise.sh @@ -19,6 +19,7 @@ omarchy-mise-install npm:@kitlangton/ghui ghui omarchy-mise-install aqua:modem-dev/hunk hunk omarchy-mise-install github:basecamp/hey-cli hey omarchy-mise-install github:basecamp/basecamp-cli basecamp +omarchy-mise-install npm:cf cf omarchy-mise-install github:OpenRouterLabs/ori-releases ori # Every line above writes a stub and cannot fail. This one can: it exits # non-zero when Hermes Desktop owns Hermes but has not finished setting it up, diff --git a/migrations/1789310715.sh b/migrations/1789310715.sh new file mode 100644 index 00000000..a182dc8b --- /dev/null +++ b/migrations/1789310715.sh @@ -0,0 +1,5 @@ +echo "Install cf (Cloudflare CLI) via mise wrapper" + +if [[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]; then + omarchy-mise-install npm:cf cf +fi From 9ca462b153637bd28d749bc491a2e72e139e2993 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 13 Sep 2026 17:05:40 +0200 Subject: [PATCH 34/48] Match 1Password's new window class so it floats again 1Password 8.12 changed its app id from "1Password" to "com.onepassword.OnePassword" (its .desktop file now declares that as StartupWMClass). Our window rule only matched the old name, so the main window came up tiled and lost the no_screen_share protection. Match both forms so older installs keep working. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LSFKDatumRZHB8zqk5CP5C --- default/hypr/apps/1password.lua | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/default/hypr/apps/1password.lua b/default/hypr/apps/1password.lua index d57bf651..15683599 100644 --- a/default/hypr/apps/1password.lua +++ b/default/hypr/apps/1password.lua @@ -1 +1,3 @@ -o.window("^(1[p|P]assword)$", { no_screen_share = true, tag = "+floating-window" }) +-- 1Password 8.12 renamed its app id from "1Password" to the reverse-DNS form, +-- so match both to keep older installs floating too. +o.window("^(1[pP]assword|com\\.onepassword\\.OnePassword)$", { no_screen_share = true, tag = "+floating-window" }) From d78ea9e158727a9e384858059080c61a7264f842 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 13 Sep 2026 17:20:29 +0200 Subject: [PATCH 35/48] Point GitHub URLs at omacom/omarchy instead of basecamp/omarchy The repo moved to the omacom org. GitHub redirects the old URLs, but `omarchy channel set dev` was still cloning from basecamp/omarchy, which left every dev checkout with a stale origin remote that confuses gh (pr create fails with "No commits between omacom:quattro and basecamp:"). Update the clone URL, the quattro upgrade tarball, the update-confirm release link, the systemd Documentation link, and the manual. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LSFKDatumRZHB8zqk5CP5C --- bin/omarchy-channel-set | 2 +- bin/omarchy-update-confirm | 2 +- bin/omarchy-upgrade-to-quattro | 2 +- default/hypr/apps/jetbrains.lua | 2 +- default/systemd/user/omarchy-speaker-tuning.service | 2 +- manual/30-updates.md | 4 ++-- manual/49-omarchy-on.md | 6 +++--- test/shell.d/channel-test.sh | 4 ++-- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/bin/omarchy-channel-set b/bin/omarchy-channel-set index d4c482f2..baceaa92 100755 --- a/bin/omarchy-channel-set +++ b/bin/omarchy-channel-set @@ -34,7 +34,7 @@ validate_dev_checkout() { link_dev_checkout() { local checkout="$1" - [[ -d $checkout/.git ]] || git clone https://github.com/basecamp/omarchy.git "$checkout" + [[ -d $checkout/.git ]] || git clone https://github.com/omacom/omarchy.git "$checkout" omarchy-dev-link "$checkout" --no-reboot } diff --git a/bin/omarchy-update-confirm b/bin/omarchy-update-confirm index 472927f1..c3ca37e6 100755 --- a/bin/omarchy-update-confirm +++ b/bin/omarchy-update-confirm @@ -8,7 +8,7 @@ gum style --border normal --padding "1 2" \ "• You cannot stop the update once you start!" \ "• Make sure you're connected to power or have a full battery" \ "" \ - "What's new: https://github.com/basecamp/omarchy/releases/latest" + "What's new: https://github.com/omacom/omarchy/releases/latest" echo diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index e6a27ee6..86f1efce 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -965,7 +965,7 @@ populate_legacy_hypr_defaults() { tmp_dir=$(mktemp -d) archive_dir="$tmp_dir/archive" mkdir -p "$archive_dir" - if curl -fsSL https://github.com/basecamp/omarchy/archive/refs/heads/master.tar.gz | tar -xz -C "$archive_dir" && + if curl -fsSL https://github.com/omacom/omarchy/archive/refs/heads/master.tar.gz | tar -xz -C "$archive_dir" && [[ -d $archive_dir/omarchy-master/default/hypr ]]; then cp -a "$archive_dir/omarchy-master/default/hypr/." "$shim_hypr_dir/" fi diff --git a/default/hypr/apps/jetbrains.lua b/default/hypr/apps/jetbrains.lua index 678e823e..1b9658e3 100644 --- a/default/hypr/apps/jetbrains.lua +++ b/default/hypr/apps/jetbrains.lua @@ -1,2 +1,2 @@ --- Disable mouse focus (see https://github.com/basecamp/omarchy/pull/5183#issuecomment-4189299971). +-- Disable mouse focus (see https://github.com/omacom/omarchy/pull/5183#issuecomment-4189299971). o.window("^(jetbrains-.*)$", { no_follow_mouse = true }) diff --git a/default/systemd/user/omarchy-speaker-tuning.service b/default/systemd/user/omarchy-speaker-tuning.service index 53b37447..ed6145a9 100644 --- a/default/systemd/user/omarchy-speaker-tuning.service +++ b/default/systemd/user/omarchy-speaker-tuning.service @@ -1,6 +1,6 @@ [Unit] Description=Omarchy speaker tuning filter-chain -Documentation=https://github.com/basecamp/omarchy/blob/master/docs/audio-tuning.md +Documentation=https://github.com/omacom/omarchy/blob/master/docs/audio-tuning.md # WirePlumber does the linking, so starting before it is up risks the output being # linked before the speaker device has been discovered. After=pipewire.service wireplumber.service diff --git a/manual/30-updates.md b/manual/30-updates.md index 246138c3..f0e82ced 100644 --- a/manual/30-updates.md +++ b/manual/30-updates.md @@ -2,7 +2,7 @@ Omarchy and your packages are kept up to date via _Update > Omarchy_ in the Omarchy menu (`Super + Space`). -Omarchy itself is installed as regular pacman packages from the [Omarchy Package Repository](https://github.com/omacom-io/omarchy-pkgs), so an update installs [the latest Omarchy release](https://github.com/basecamp/omarchy/releases), runs any pending migrations to get your system in sync with the latest, and updates all system packages from the [Omarchy Arch Mirror](https://github.com/omacom-io/omarchy-mirror) and [AUR](https://aur.archlinux.org/) (if you have installed any AUR packages). +Omarchy itself is installed as regular pacman packages from the [Omarchy Package Repository](https://github.com/omacom-io/omarchy-pkgs), so an update installs [the latest Omarchy release](https://github.com/omacom/omarchy/releases), runs any pending migrations to get your system in sync with the latest, and updates all system packages from the [Omarchy Arch Mirror](https://github.com/omacom-io/omarchy-mirror) and [AUR](https://aur.archlinux.org/) (if you have installed any AUR packages). When new releases are made, a circle arrow icon will appear to the right of your clock. Click it and the update process will start. @@ -10,7 +10,7 @@ When new releases are made, a circle arrow icon will appear to the right of your ### Four channels -Omarchy is updated along four channels: stable, RC, edge, and dev. New installations start on the stable channel, which tracks the [official releases](https://github.com/basecamp/omarchy/releases/), as well as the [stable Omarchy Arch mirror](https://github.com/omacom-io/omarchy-mirror) that's running one month behind the latest, so we can catch any new incompatibilities that require config changes before they cause problems for people. +Omarchy is updated along four channels: stable, RC, edge, and dev. New installations start on the stable channel, which tracks the [official releases](https://github.com/omacom/omarchy/releases/), as well as the [stable Omarchy Arch mirror](https://github.com/omacom-io/omarchy-mirror) that's running one month behind the latest, so we can catch any new incompatibilities that require config changes before they cause problems for people. But if you'd like to help spot those potential issues, you can run on the edge channel. That'll keep your Omarchy packages tracking the latest development builds, and lets you update to the latest Arch packages as soon as they're available. You should only do this if you're experienced with Linux, and know how to recover a system that has problems. diff --git a/manual/49-omarchy-on.md b/manual/49-omarchy-on.md index c33642fd..af066999 100644 --- a/manual/49-omarchy-on.md +++ b/manual/49-omarchy-on.md @@ -6,15 +6,15 @@ ### Apple Virtual Machine -You can also install Omarchy inside a Parallels VM. Quite the cumbersome process, but there's [a user-driven guide](https://github.com/basecamp/omarchy/discussions/452) for that too. +You can also install Omarchy inside a Parallels VM. Quite the cumbersome process, but there's [a user-driven guide](https://github.com/omacom/omarchy/discussions/452) for that too. ### VirtualBox -VirtualBox is a popular VM runner. [You can run Omarchy inside that too](https://github.com/basecamp/omarchy/discussions/176). But performance probably won't be great. +VirtualBox is a popular VM runner. [You can run Omarchy inside that too](https://github.com/omacom/omarchy/discussions/176). But performance probably won't be great. ### VMware Workstation on Windows 11 -Another popular VM runner for Windows. [Omarchy has been setup inside of that as well](https://github.com/basecamp/omarchy/discussions/572). +Another popular VM runner for Windows. [Omarchy has been setup inside of that as well](https://github.com/omacom/omarchy/discussions/572). ### Steam Deck diff --git a/test/shell.d/channel-test.sh b/test/shell.d/channel-test.sh index 122a15aa..7e7d4170 100644 --- a/test/shell.d/channel-test.sh +++ b/test/shell.d/channel-test.sh @@ -153,11 +153,11 @@ run_channel dev assert_log_line $'gum\tconfirm\t--default=false\tSwitch to dev channel?' "dev asks for confirmation" assert_log_line $'refresh\tedge' "dev refreshes the edge pacman channel" assert_log_line $'update-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "dev installs development Omarchy packages" -assert_log_line $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout" "dev clones the source checkout to ~/omarchy" +assert_log_line $'git\tclone\thttps://github.com/omacom/omarchy.git\t'"$checkout" "dev clones the source checkout to ~/omarchy" assert_log_line $'link\t'"$checkout"$'\t--no-reboot' "dev links ~/omarchy without an early reboot prompt" assert_log_line $'state\tset\treboot-required' "dev defers the reboot prompt to the update pipeline" assert_log_line $'update\t-y\tOMARCHY_PATH='"$checkout" "dev runs the normal update pipeline from the source checkout" -[[ $(grep -E '^(git|link|state|refresh|sudo|update)' "$log_file") == $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout"$'\nlink\t'"$checkout"$'\t--no-reboot\nstate\tset\treboot-required\nrefresh\tedge\nupdate-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev\nupdate\t-y\tOMARCHY_PATH='"$checkout" ]] || +[[ $(grep -E '^(git|link|state|refresh|sudo|update)' "$log_file") == $'git\tclone\thttps://github.com/omacom/omarchy.git\t'"$checkout"$'\nlink\t'"$checkout"$'\t--no-reboot\nstate\tset\treboot-required\nrefresh\tedge\nupdate-pacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev\nupdate\t-y\tOMARCHY_PATH='"$checkout" ]] || fail "dev activates the checkout before changing or updating packages" "$(cat "$log_file")" pass "dev activates the checkout before changing or updating packages" From 8369601f26b41164ab285f61d35434f6933479de Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 13 Sep 2026 17:58:17 +0200 Subject: [PATCH 36/48] Remove CODEOWNERS No branch on the repo is protected and there are no rulesets, so the file never enforced owner review. Its only effect was auto-requesting a review from the other owner on every PR, which nobody acted on. Co-Authored-By: Claude Fable 5.1 --- .github/CODEOWNERS | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 .github/CODEOWNERS diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index 4e9460dd..00000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,2 +0,0 @@ -# Merges to protected branches need sign-off from an org owner. -* @dhh @ryanrhughes From 8a972da9756dbebe4e46512079363278dfb2a62a Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Sun, 13 Sep 2026 14:43:47 -0400 Subject: [PATCH 37/48] Migrate Panther Lake systems to the Omarchy PTL kernel --- etc/limine-entry-tool.d/omarchy-defaults.conf | 2 +- install/hardware/all.sh | 2 +- install/hardware/fix-elgato-camlink-4k.sh | 2 +- install/hardware/intel/ptl-kernel.sh | 13 +- install/hardware/nvidia.sh | 2 +- install/omarchy-other.packages | 4 +- migrations/1789095456.sh | 45 ++++ test/shell.d/limine-defaults-test.sh | 4 + test/shell.d/ptl-kernel-migration-test.sh | 195 ++++++++++++++++++ 9 files changed, 257 insertions(+), 12 deletions(-) create mode 100644 migrations/1789095456.sh create mode 100644 test/shell.d/ptl-kernel-migration-test.sh diff --git a/etc/limine-entry-tool.d/omarchy-defaults.conf b/etc/limine-entry-tool.d/omarchy-defaults.conf index 49c6204b..1703dab4 100644 --- a/etc/limine-entry-tool.d/omarchy-defaults.conf +++ b/etc/limine-entry-tool.d/omarchy-defaults.conf @@ -15,7 +15,7 @@ ENABLE_LIMINE_FALLBACK=yes # Find and add other bootloaders FIND_BOOTLOADERS=yes -BOOT_ORDER="*, *fallback, Snapshots" +BOOT_ORDER="linux-omarchy-*, *, *fallback, Snapshots" # Snapper is configured with NUMBER_LIMIT="5" (see default/snapper/root), but # limine-snapper-sync can see the newly created sixth snapshot before cleanup diff --git a/install/hardware/all.sh b/install/hardware/all.sh index defeef1c..df695c65 100644 --- a/install/hardware/all.sh +++ b/install/hardware/all.sh @@ -17,7 +17,7 @@ run_logged "$OMARCHY_INSTALL/hardware/intel/thermald.sh" # Swap in the Panther Lake kernel before anything pulls DKMS modules in. # intel-ipu7-camera drags in ipu7-drivers, vision-drivers and v4l2loopback, # and building all three against the stock kernel only to rebuild them against -# linux-ptl and tear the first set down again cost ~25s of the install. +# the PTL kernel and tear the first set down again cost ~25s of the install. run_logged "$OMARCHY_INSTALL/hardware/intel/ptl-kernel.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/ipu7-camera.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/fred.sh" diff --git a/install/hardware/fix-elgato-camlink-4k.sh b/install/hardware/fix-elgato-camlink-4k.sh index f726fd9a..eec9771f 100644 --- a/install/hardware/fix-elgato-camlink-4k.sh +++ b/install/hardware/fix-elgato-camlink-4k.sh @@ -4,7 +4,7 @@ # v4l2-relayd re-exposes it at 1280x720 under the same name. if omarchy-hw-elgato-camlink-4k; then - mapfile -t kernel_headers < <(pacman -Qqs '^linux(-zen|-lts|-hardened|-t2|-ptl|-omarchy-bore)?$' | sed 's/$/-headers/') + mapfile -t kernel_headers < <(pacman -Qqs '^linux(-zen|-lts|-hardened|-t2|-ptl|-omarchy-bore|-omarchy-ptl-novrr-mm)?$' | sed 's/$/-headers/') omarchy-pkg-add "${kernel_headers[@]}" v4l2loopback-dkms v4l2loopback-utils v4l2-relayd sudo install -Dm644 "$OMARCHY_PATH/default/udev/elgato-camlink-4k.rules" /etc/udev/rules.d/71-elgato-camlink-4k.rules diff --git a/install/hardware/intel/ptl-kernel.sh b/install/hardware/intel/ptl-kernel.sh index 79482724..12659a28 100644 --- a/install/hardware/intel/ptl-kernel.sh +++ b/install/hardware/intel/ptl-kernel.sh @@ -1,16 +1,17 @@ # Install Panther Lake kernel for Dell XPS Panther Lake systems -# The linux-ptl kernel includes audio driver patches not yet in mainline. +# The Omarchy PTL kernel includes Panther Lake display and memory fixes. if omarchy-hw-match "XPS" && omarchy-hw-intel-ptl; then echo "Detected Dell XPS Panther Lake, installing PTL kernel..." - omarchy-pkg-add linux-ptl linux-ptl-headers + # linux-ptl required SOF firmware; the new kernel only lists it as optional. + omarchy-pkg-add linux-omarchy-ptl-novrr-mm linux-omarchy-ptl-novrr-mm-headers sof-firmware pacman -Rdd --noconfirm linux linux-headers || true - # linux-ptl doesn't provide=linux, so anything depending on linux drags the + # The PTL kernel doesn't provide=linux, so anything depending on linux drags the # stock kernel back in and the boot menu grows a second, slower entry. if pacman -Qq linux &>/dev/null; then - echo "WARNING: stock linux kernel still installed alongside linux-ptl:" + echo "WARNING: stock linux kernel still installed alongside the Omarchy PTL kernel:" pacman -Qi linux | grep -i "required by" fi @@ -19,7 +20,7 @@ if omarchy-hw-match "XPS" && omarchy-hw-intel-ptl; then # the last BOOT_ORDER wins, so an earlier-sorting name is a silent no-op. rm -f /etc/limine-entry-tool.d/dell-xps-panther-lake.conf cat > /etc/limine-entry-tool.d/zz-dell-xps-panther-lake.conf <<'EOF' -# Only show Panther Lake kernel in boot menu on Dell XPS Panther Lake -BOOT_ORDER="linux-ptl*, *fallback, Snapshots" +# Prefer Omarchy kernels while keeping other kernels available for recovery +BOOT_ORDER="linux-omarchy-*, *, *fallback, Snapshots" EOF fi diff --git a/install/hardware/nvidia.sh b/install/hardware/nvidia.sh index e7370995..81e1556e 100644 --- a/install/hardware/nvidia.sh +++ b/install/hardware/nvidia.sh @@ -1,6 +1,6 @@ if lspci | grep -qi 'nvidia'; then # Check which kernel is installed and set appropriate headers package - KERNEL_PACKAGE=$(pacman -Qqs '^linux(-zen|-lts|-hardened|-t2|-ptl)?$' | head -1 || true) + KERNEL_PACKAGE=$(pacman -Qqs '^linux(-zen|-lts|-hardened|-t2|-ptl|-omarchy-ptl-novrr-mm)?$' | head -1 || true) [[ -n $KERNEL_PACKAGE ]] && omarchy-pkg-add "$KERNEL_PACKAGE-headers" if omarchy-hw-nvidia-gsp; then diff --git a/install/omarchy-other.packages b/install/omarchy-other.packages index 3214657a..873e5048 100644 --- a/install/omarchy-other.packages +++ b/install/omarchy-other.packages @@ -23,8 +23,8 @@ limine-snapper-sync linux linux-firmware linux-headers -linux-ptl -linux-ptl-headers +linux-omarchy-ptl-novrr-mm +linux-omarchy-ptl-novrr-mm-headers macbook12-spi-driver-dkms nvidia-580xx-dkms nvidia-dkms diff --git a/migrations/1789095456.sh b/migrations/1789095456.sh new file mode 100644 index 00000000..574d3d9e --- /dev/null +++ b/migrations/1789095456.sh @@ -0,0 +1,45 @@ +echo "Install the Omarchy Panther Lake kernel and prefer Omarchy kernels in Limine" + +omarchy-pkg-present linux-ptl || exit 0 + +limine_conf="${OMARCHY_PTL_LIMINE_CONF:-/etc/default/limine}" +limine_drop_ins="${OMARCHY_PTL_LIMINE_DROP_INS:-/etc/limine-entry-tool.d}" +rebuild_marker="${OMARCHY_PTL_REBUILD_MARKER:-/var/lib/omarchy/migrations/1789095456}" +kernel="linux-omarchy-ptl-novrr-mm" + +# Completion is machine-wide even though migrations run once per user. Leave +# the old kernel installed so it remains available if the new one cannot boot. +[[ ! -e $rebuild_marker ]] || exit 0 +omarchy-pkg-add "$kernel" "$kernel-headers" + +# /etc/default/limine and the Dell drop-ins were written by the installer. +# omarchy-settings ships omarchy-defaults.conf, but pacman's backup protection +# can leave its update in a .pacnew when other settings were customized. Repair +# only the old boot order in the active files, preserving custom orders and +# unrelated settings, including the root filesystem's kernel command line. +for conf in "$limine_conf" \ + "$limine_drop_ins/omarchy-defaults.conf" \ + "$limine_drop_ins/dell-xps-panther-lake.conf" \ + "$limine_drop_ins/zz-dell-xps-panther-lake.conf"; do + if [[ -f $conf ]]; then + sudo sed -i -E \ + 's/^([[:space:]]*BOOT_ORDER=)"(\*|linux-ptl\*), \*fallback, Snapshots"([[:space:]]*(#.*)?)$/\1"linux-omarchy-*, *, *fallback, Snapshots"\3/' \ + "$conf" + fi +done + +# Package hooks ran before the config repair. Rebuild the new kernel's image +# and boot entry explicitly, including on retries after a failed rebuild. +sudo limine-mkinitcpio "$kernel" + +# limine-mkinitcpio can return success after skipping a failed kernel build. +# Do not mark the migration complete unless the new kernel is in the menu. +if ! sudo limine-entry-tool --tree | grep -F "$kernel" >/dev/null; then + echo "The new Panther Lake kernel has no Limine boot entry; rerun omarchy-migrate after fixing the boot image build." >&2 + exit 1 +fi + +# Keeping the running kernel installed prevents the updater from detecting a +# kernel replacement, so request the reboot explicitly. +omarchy-state set reboot-required +sudo install -Dm644 /dev/null "$rebuild_marker" diff --git a/test/shell.d/limine-defaults-test.sh b/test/shell.d/limine-defaults-test.sh index e4c420ff..cf93ebdf 100644 --- a/test/shell.d/limine-defaults-test.sh +++ b/test/shell.d/limine-defaults-test.sh @@ -9,3 +9,7 @@ packaged_defaults="$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" grep -Fq 'KERNEL_CMDLINE[default]+=" initramfs_async=0"' "$packaged_defaults" || fail "the packaged Limine defaults still unpack the initramfs synchronously" pass "packaged Limine defaults keep Plymouth alive at the LUKS prompt" + +grep -Fxq 'BOOT_ORDER="linux-omarchy-*, *, *fallback, Snapshots"' "$packaged_defaults" || + fail "packaged Limine defaults prefer Omarchy kernels and retain recovery entries" +pass "packaged Limine defaults prefer Omarchy kernels and retain recovery entries" diff --git a/test/shell.d/ptl-kernel-migration-test.sh b/test/shell.d/ptl-kernel-migration-test.sh new file mode 100644 index 00000000..874397c0 --- /dev/null +++ b/test/shell.d/ptl-kernel-migration-test.sh @@ -0,0 +1,195 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +migration="$ROOT/migrations/1789095456.sh" +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT +mkdir -p "$scratch/bin" "$scratch/drop-ins" + +export PATH="$scratch/bin:$ROOT/bin:$PATH" +export CALL_LOG="$scratch/calls" +export INSTALLED_PACKAGES="$scratch/packages" +export OMARCHY_PTL_LIMINE_CONF="$scratch/limine" +export OMARCHY_PTL_LIMINE_DROP_INS="$scratch/drop-ins" +export OMARCHY_PTL_REBUILD_MARKER="$scratch/state/completed" +kernel="linux-omarchy-ptl-novrr-mm" +boot_order='BOOT_ORDER="linux-omarchy-*, *, *fallback, Snapshots"' + +# Exercise the real package helpers, including their post-install queries. +cat > "$scratch/bin/pacman" <<'SH' +#!/bin/bash +case "$1" in + -Q) grep -Fxq "$2" "$INSTALLED_PACKAGES" ;; + -S) + printf 'pacman %s\n' "$*" >> "$CALL_LOG" + [[ ${INSTALL_FAIL:-0} == "0" ]] || exit 1 + for arg in "$@"; do + [[ $arg == -* ]] || printf '%s\n' "$arg" >> "$INSTALLED_PACKAGES" + done + ;; + *) exit 99 ;; +esac +SH + +cat > "$scratch/bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo %s\n' "$*" >> "$CALL_LOG" +case "$1" in + pacman | sed | install | limine-mkinitcpio | limine-entry-tool) exec "$@" ;; + *) exit 99 ;; +esac +SH + +cat > "$scratch/bin/limine-mkinitcpio" <<'SH' +#!/bin/bash +[[ ${REBUILD_FAIL:-0} == "0" ]] +SH + +cat > "$scratch/bin/limine-entry-tool" <<'SH' +#!/bin/bash +[[ $* == "--tree" ]] || exit 99 +printf '%s\n' 'Omarchy' ' linux-ptl' ' Snapshots' +if [[ ${MISSING_ENTRY:-0} == "0" ]]; then + printf '%s\n' ' linux-omarchy-ptl-novrr-mm' +fi +SH + +cat > "$scratch/bin/omarchy-state" <<'SH' +#!/bin/bash +[[ $* == "set reboot-required" ]] || exit 99 +printf 'state %s\n' "$*" >> "$CALL_LOG" +SH +chmod +x "$scratch/bin/"* + +reset_fixture() { + : > "$CALL_LOG" + printf '%s\n' linux-ptl linux-ptl-headers > "$INSTALLED_PACKAGES" + rm -f "$OMARCHY_PTL_REBUILD_MARKER" "$OMARCHY_PTL_LIMINE_DROP_INS/"*.conf + cat > "$OMARCHY_PTL_LIMINE_CONF" <<'CONF' +KERNEL_CMDLINE[default]="root=UUID=keep-me rw cryptdevice=UUID=keep-me:root" +BOOT_ORDER="*, *fallback, Snapshots" +ENABLE_UKI=yes +CONF + cp "$OMARCHY_PTL_LIMINE_CONF" "$scratch/original-limine" +} + +run_migration() { + bash -euo pipefail "$migration" > "$scratch/output" 2>&1 +} + +assert_preferred() { + grep -Fxq "$boot_order" "$1" || fail "Omarchy kernels are preferred in $1" +} + +reset_fixture +printf '%s\n' linux > "$INSTALLED_PACKAGES" +run_migration +[[ ! -s $CALL_LOG ]] || fail "systems without linux-ptl do not change" +cmp -s "$OMARCHY_PTL_LIMINE_CONF" "$scratch/original-limine" || fail "unaffected Limine settings stay unchanged" +[[ ! -e $OMARCHY_PTL_REBUILD_MARKER ]] || fail "unaffected systems do not get a completion marker" +pass "systems without linux-ptl are skipped" + +reset_fixture +for name in dell-xps-panther-lake zz-dell-xps-panther-lake; do + printf '%s\n' 'BOOT_ORDER="linux-ptl*, *fallback, Snapshots"' > "$OMARCHY_PTL_LIMINE_DROP_INS/$name.conf" +done +cp "$OMARCHY_PTL_LIMINE_CONF" "$OMARCHY_PTL_LIMINE_DROP_INS/omarchy-defaults.conf" +run_migration +grep -Fxq "pacman -S --noconfirm --needed $kernel $kernel-headers" "$CALL_LOG" || fail "both new packages are installed" +grep -Fxq linux-ptl "$INSTALLED_PACKAGES" || fail "the old kernel is kept for recovery" +grep -Fxq linux-ptl-headers "$INSTALLED_PACKAGES" || fail "the old kernel headers are kept" +assert_preferred "$OMARCHY_PTL_LIMINE_CONF" +for conf in "$OMARCHY_PTL_LIMINE_DROP_INS/"*.conf; do + assert_preferred "$conf" +done +diff -u <(sed '/^BOOT_ORDER=/d' "$scratch/original-limine") \ + <(sed '/^BOOT_ORDER=/d' "$OMARCHY_PTL_LIMINE_CONF") || fail "kernel command line and unrelated settings are preserved" +grep -Fxq "sudo limine-mkinitcpio $kernel" "$CALL_LOG" || fail "the new kernel's boot image is rebuilt" +[[ -f $OMARCHY_PTL_REBUILD_MARKER ]] || fail "successful completion is recorded" +grep -Fxq 'state set reboot-required' "$CALL_LOG" || fail "the updater must offer a reboot when retaining the old kernel" +pass "new packages install, all stock boot orders are repaired, and the old kernel remains available" + +: > "$CALL_LOG" +run_migration +[[ ! -s $CALL_LOG ]] || fail "another user's run does not repeat the machine-wide migration" +pass "repeat runs are a no-op after successful completion" + +reset_fixture +printf '%s\n' "$kernel" >> "$INSTALLED_PACKAGES" +run_migration +grep -Fxq "$kernel-headers" "$INSTALLED_PACKAGES" || fail "missing headers install when the kernel is already present" +pass "a partially installed kernel gets its missing headers" + +reset_fixture +printf '%s\n' "$kernel" "$kernel-headers" >> "$INSTALLED_PACKAGES" +run_migration +! grep -q '^pacman -S' "$CALL_LOG" || fail "already installed packages are not reinstalled" +assert_preferred "$OMARCHY_PTL_LIMINE_CONF" +pass "existing new packages still receive the config repair and boot rebuild" + +reset_fixture +if INSTALL_FAIL=1 run_migration; then + fail "package installation failure must fail the migration" +fi +cmp -s "$OMARCHY_PTL_LIMINE_CONF" "$scratch/original-limine" || fail "install failure leaves the config untouched" +! grep -q 'limine-mkinitcpio' "$CALL_LOG" || fail "install failure does not rebuild" +[[ ! -e $OMARCHY_PTL_REBUILD_MARKER ]] || fail "install failure stays pending" +pass "package failures leave the old boot setup intact and the migration pending" + +reset_fixture +if REBUILD_FAIL=1 run_migration; then + fail "boot rebuild failure must fail the migration" +fi +[[ ! -e $OMARCHY_PTL_REBUILD_MARKER ]] || fail "rebuild failure stays pending" +! grep -q '^state ' "$CALL_LOG" || fail "rebuild failure must not request a reboot" +assert_preferred "$OMARCHY_PTL_LIMINE_CONF" +: > "$CALL_LOG" +run_migration +grep -Fxq "sudo limine-mkinitcpio $kernel" "$CALL_LOG" || fail "retry must rebuild even after config and packages are repaired" +[[ -f $OMARCHY_PTL_REBUILD_MARKER ]] || fail "retry records successful completion" +pass "a failed rebuild is retried even after config and package changes succeeded" + +reset_fixture +if MISSING_ENTRY=1 run_migration; then + fail "a silently skipped kernel build must fail the migration" +fi +[[ ! -e $OMARCHY_PTL_REBUILD_MARKER ]] || fail "a missing boot entry stays pending" +! grep -q '^state ' "$CALL_LOG" || fail "a missing boot entry must not request a reboot" +run_migration +[[ -f $OMARCHY_PTL_REBUILD_MARKER ]] || fail "a missing boot entry can be repaired on retry" +pass "a successful command without a new boot entry cannot complete the migration" + +reset_fixture +printf '%s\n' 'BOOT_ORDER="linux-lts, *, *fallback, Snapshots"' > "$OMARCHY_PTL_LIMINE_CONF" +cp "$OMARCHY_PTL_LIMINE_CONF" "$scratch/custom-limine" +run_migration +cmp -s "$OMARCHY_PTL_LIMINE_CONF" "$scratch/custom-limine" || fail "custom boot orders stay unchanged" +pass "administrator-defined boot orders are preserved" + +reset_fixture +cp "$OMARCHY_PTL_LIMINE_CONF" "$OMARCHY_PTL_LIMINE_DROP_INS/omarchy-defaults.conf" +printf '%s\n' 'ENABLE_SORT=yes' >> "$OMARCHY_PTL_LIMINE_DROP_INS/omarchy-defaults.conf" +cp "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" "$OMARCHY_PTL_LIMINE_DROP_INS/omarchy-defaults.conf.pacnew" +run_migration +assert_preferred "$OMARCHY_PTL_LIMINE_DROP_INS/omarchy-defaults.conf" +grep -Fxq 'ENABLE_SORT=yes' "$OMARCHY_PTL_LIMINE_DROP_INS/omarchy-defaults.conf" || fail "custom packaged settings survive the repair" +cmp -s "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" \ + "$OMARCHY_PTL_LIMINE_DROP_INS/omarchy-defaults.conf.pacnew" || fail "the pending pacnew is left for the administrator to merge" +pass "active packaged defaults are repaired when customized settings leave the update in a pacnew" + +reset_fixture +printf '%s\n' ' BOOT_ORDER="*, *fallback, Snapshots" # keep this comment' > "$OMARCHY_PTL_LIMINE_CONF" +run_migration +grep -Fxq " $boot_order # keep this comment" "$OMARCHY_PTL_LIMINE_CONF" || fail "stock orders with comments are repaired" +pass "indentation and comments survive the repair" + +reset_fixture +rm "$OMARCHY_PTL_LIMINE_CONF" +cp "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" "$OMARCHY_PTL_LIMINE_DROP_INS/omarchy-defaults.conf" +run_migration +[[ ! -e $OMARCHY_PTL_LIMINE_CONF ]] || fail "missing central config is not fabricated" +assert_preferred "$OMARCHY_PTL_LIMINE_DROP_INS/omarchy-defaults.conf" +pass "installs without a central config inherit the packaged boot order" From ff85faf8dda228de9c6a6d3eb0c74cdde8b3bb99 Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Mon, 14 Sep 2026 16:32:09 -0400 Subject: [PATCH 38/48] Make linux-omarchy the default kernel except on T2 Macs --- etc/limine-entry-tool.d/omarchy-defaults.conf | 3 +- install/hardware/all.sh | 11 +- install/hardware/fix-elgato-camlink-4k.sh | 2 +- install/hardware/intel/ptl-kernel.sh | 26 -- install/hardware/kernel.sh | 9 + install/hardware/nvidia.sh | 2 +- install/omarchy-other.packages | 4 +- migrations/1789095456.sh | 45 ---- migrations/1789325478.sh | 44 ++++ test/shell.d/limine-defaults-test.sh | 6 +- test/shell.d/omarchy-kernel-install-test.sh | 84 ++++++ test/shell.d/omarchy-kernel-migration-test.sh | 248 ++++++++++++++++++ test/shell.d/ptl-kernel-migration-test.sh | 195 -------------- test/shell.d/xps13-sidecar-amps-test.sh | 11 +- 14 files changed, 402 insertions(+), 288 deletions(-) delete mode 100644 install/hardware/intel/ptl-kernel.sh create mode 100644 install/hardware/kernel.sh delete mode 100644 migrations/1789095456.sh create mode 100644 migrations/1789325478.sh create mode 100644 test/shell.d/omarchy-kernel-install-test.sh create mode 100644 test/shell.d/omarchy-kernel-migration-test.sh delete mode 100644 test/shell.d/ptl-kernel-migration-test.sh diff --git a/etc/limine-entry-tool.d/omarchy-defaults.conf b/etc/limine-entry-tool.d/omarchy-defaults.conf index 1703dab4..ec0fa96f 100644 --- a/etc/limine-entry-tool.d/omarchy-defaults.conf +++ b/etc/limine-entry-tool.d/omarchy-defaults.conf @@ -15,7 +15,8 @@ ENABLE_LIMINE_FALLBACK=yes # Find and add other bootloaders FIND_BOOTLOADERS=yes -BOOT_ORDER="linux-omarchy-*, *, *fallback, Snapshots" +# Keep T2 Macs on their specialized kernel; prefer linux-omarchy elsewhere. +BOOT_ORDER="linux-t2, linux-omarchy, linux-omarchy-*, *, *fallback, Snapshots" # Snapper is configured with NUMBER_LIMIT="5" (see default/snapper/root), but # limine-snapper-sync can see the newly created sixth snapshot before cleanup diff --git a/install/hardware/all.sh b/install/hardware/all.sh index df695c65..d40069b2 100644 --- a/install/hardware/all.sh +++ b/install/hardware/all.sh @@ -1,3 +1,5 @@ +run_logged "$OMARCHY_INSTALL/hardware/kernel.sh" + run_logged "$OMARCHY_INSTALL/hardware/asus-rog.sh" run_logged "$OMARCHY_INSTALL/hardware/framework16.sh" run_logged "$OMARCHY_INSTALL/hardware/dell-xps-touchpad-haptics.sh" @@ -14,21 +16,14 @@ run_logged "$OMARCHY_INSTALL/hardware/vulkan.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/video-acceleration.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/lpmd.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/thermald.sh" -# Swap in the Panther Lake kernel before anything pulls DKMS modules in. -# intel-ipu7-camera drags in ipu7-drivers, vision-drivers and v4l2loopback, -# and building all three against the stock kernel only to rebuild them against -# the PTL kernel and tear the first set down again cost ~25s of the install. -run_logged "$OMARCHY_INSTALL/hardware/intel/ptl-kernel.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/ipu7-camera.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/fred.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/fix-wifi7-eht.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/sof-firmware.sh" -# Pulls in v4l2loopback-dkms, so it sits after the Panther Lake kernel swap. run_logged "$OMARCHY_INSTALL/hardware/fix-elgato-camlink-4k.sh" -# Rebuilds the boot image, so it has to follow the Panther Lake kernel swap -# above rather than sit with the other Dell leaf at the top of this file. +# Rebuilds the boot image, so it follows kernel and camera module setup. run_logged "$OMARCHY_INSTALL/hardware/dell-xps13-sidecar-amps.sh" run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-display-backlight.sh" diff --git a/install/hardware/fix-elgato-camlink-4k.sh b/install/hardware/fix-elgato-camlink-4k.sh index eec9771f..274076ce 100644 --- a/install/hardware/fix-elgato-camlink-4k.sh +++ b/install/hardware/fix-elgato-camlink-4k.sh @@ -4,7 +4,7 @@ # v4l2-relayd re-exposes it at 1280x720 under the same name. if omarchy-hw-elgato-camlink-4k; then - mapfile -t kernel_headers < <(pacman -Qqs '^linux(-zen|-lts|-hardened|-t2|-ptl|-omarchy-bore|-omarchy-ptl-novrr-mm)?$' | sed 's/$/-headers/') + mapfile -t kernel_headers < <(pacman -Qqs '^linux(-zen|-lts|-hardened|-t2|-ptl|-omarchy(-bore|-ptl-novrr-mm)?)?$' | sed 's/$/-headers/') omarchy-pkg-add "${kernel_headers[@]}" v4l2loopback-dkms v4l2loopback-utils v4l2-relayd sudo install -Dm644 "$OMARCHY_PATH/default/udev/elgato-camlink-4k.rules" /etc/udev/rules.d/71-elgato-camlink-4k.rules diff --git a/install/hardware/intel/ptl-kernel.sh b/install/hardware/intel/ptl-kernel.sh deleted file mode 100644 index 12659a28..00000000 --- a/install/hardware/intel/ptl-kernel.sh +++ /dev/null @@ -1,26 +0,0 @@ -# Install Panther Lake kernel for Dell XPS Panther Lake systems -# The Omarchy PTL kernel includes Panther Lake display and memory fixes. - -if omarchy-hw-match "XPS" && omarchy-hw-intel-ptl; then - echo "Detected Dell XPS Panther Lake, installing PTL kernel..." - - # linux-ptl required SOF firmware; the new kernel only lists it as optional. - omarchy-pkg-add linux-omarchy-ptl-novrr-mm linux-omarchy-ptl-novrr-mm-headers sof-firmware - pacman -Rdd --noconfirm linux linux-headers || true - - # The PTL kernel doesn't provide=linux, so anything depending on linux drags the - # stock kernel back in and the boot menu grows a second, slower entry. - if pacman -Qq linux &>/dev/null; then - echo "WARNING: stock linux kernel still installed alongside the Omarchy PTL kernel:" - pacman -Qi linux | grep -i "required by" - fi - - mkdir -p /etc/limine-entry-tool.d - # Named to sort after omarchy-defaults.conf: drop-ins are read in order and - # the last BOOT_ORDER wins, so an earlier-sorting name is a silent no-op. - rm -f /etc/limine-entry-tool.d/dell-xps-panther-lake.conf - cat > /etc/limine-entry-tool.d/zz-dell-xps-panther-lake.conf <<'EOF' -# Prefer Omarchy kernels while keeping other kernels available for recovery -BOOT_ORDER="linux-omarchy-*, *, *fallback, Snapshots" -EOF -fi diff --git a/install/hardware/kernel.sh b/install/hardware/kernel.sh new file mode 100644 index 00000000..ed259c66 --- /dev/null +++ b/install/hardware/kernel.sh @@ -0,0 +1,9 @@ +# Install the default kernel before hardware setup pulls in DKMS modules. +# On a fresh T2 install linux-t2 is not installed yet, so also check the chip. +if [[ $(uname -m) == "x86_64" ]] && ! omarchy-pkg-present linux-t2; then + pci_devices=$(lspci -nn) + if ! grep "106b:180[12]" <<< "$pci_devices" >/dev/null; then + echo "Installing the Omarchy kernel..." + omarchy-pkg-add linux-omarchy linux-omarchy-headers + fi +fi diff --git a/install/hardware/nvidia.sh b/install/hardware/nvidia.sh index 81e1556e..48f62cd4 100644 --- a/install/hardware/nvidia.sh +++ b/install/hardware/nvidia.sh @@ -1,6 +1,6 @@ if lspci | grep -qi 'nvidia'; then # Check which kernel is installed and set appropriate headers package - KERNEL_PACKAGE=$(pacman -Qqs '^linux(-zen|-lts|-hardened|-t2|-ptl|-omarchy-ptl-novrr-mm)?$' | head -1 || true) + KERNEL_PACKAGE=$(pacman -Qqs '^linux(-zen|-lts|-hardened|-t2|-ptl|-omarchy(-bore|-ptl-novrr-mm)?)?$' | head -1 || true) [[ -n $KERNEL_PACKAGE ]] && omarchy-pkg-add "$KERNEL_PACKAGE-headers" if omarchy-hw-nvidia-gsp; then diff --git a/install/omarchy-other.packages b/install/omarchy-other.packages index 873e5048..1f325b24 100644 --- a/install/omarchy-other.packages +++ b/install/omarchy-other.packages @@ -23,8 +23,8 @@ limine-snapper-sync linux linux-firmware linux-headers -linux-omarchy-ptl-novrr-mm -linux-omarchy-ptl-novrr-mm-headers +linux-omarchy +linux-omarchy-headers macbook12-spi-driver-dkms nvidia-580xx-dkms nvidia-dkms diff --git a/migrations/1789095456.sh b/migrations/1789095456.sh deleted file mode 100644 index 574d3d9e..00000000 --- a/migrations/1789095456.sh +++ /dev/null @@ -1,45 +0,0 @@ -echo "Install the Omarchy Panther Lake kernel and prefer Omarchy kernels in Limine" - -omarchy-pkg-present linux-ptl || exit 0 - -limine_conf="${OMARCHY_PTL_LIMINE_CONF:-/etc/default/limine}" -limine_drop_ins="${OMARCHY_PTL_LIMINE_DROP_INS:-/etc/limine-entry-tool.d}" -rebuild_marker="${OMARCHY_PTL_REBUILD_MARKER:-/var/lib/omarchy/migrations/1789095456}" -kernel="linux-omarchy-ptl-novrr-mm" - -# Completion is machine-wide even though migrations run once per user. Leave -# the old kernel installed so it remains available if the new one cannot boot. -[[ ! -e $rebuild_marker ]] || exit 0 -omarchy-pkg-add "$kernel" "$kernel-headers" - -# /etc/default/limine and the Dell drop-ins were written by the installer. -# omarchy-settings ships omarchy-defaults.conf, but pacman's backup protection -# can leave its update in a .pacnew when other settings were customized. Repair -# only the old boot order in the active files, preserving custom orders and -# unrelated settings, including the root filesystem's kernel command line. -for conf in "$limine_conf" \ - "$limine_drop_ins/omarchy-defaults.conf" \ - "$limine_drop_ins/dell-xps-panther-lake.conf" \ - "$limine_drop_ins/zz-dell-xps-panther-lake.conf"; do - if [[ -f $conf ]]; then - sudo sed -i -E \ - 's/^([[:space:]]*BOOT_ORDER=)"(\*|linux-ptl\*), \*fallback, Snapshots"([[:space:]]*(#.*)?)$/\1"linux-omarchy-*, *, *fallback, Snapshots"\3/' \ - "$conf" - fi -done - -# Package hooks ran before the config repair. Rebuild the new kernel's image -# and boot entry explicitly, including on retries after a failed rebuild. -sudo limine-mkinitcpio "$kernel" - -# limine-mkinitcpio can return success after skipping a failed kernel build. -# Do not mark the migration complete unless the new kernel is in the menu. -if ! sudo limine-entry-tool --tree | grep -F "$kernel" >/dev/null; then - echo "The new Panther Lake kernel has no Limine boot entry; rerun omarchy-migrate after fixing the boot image build." >&2 - exit 1 -fi - -# Keeping the running kernel installed prevents the updater from detecting a -# kernel replacement, so request the reboot explicitly. -omarchy-state set reboot-required -sudo install -Dm644 /dev/null "$rebuild_marker" diff --git a/migrations/1789325478.sh b/migrations/1789325478.sh new file mode 100644 index 00000000..14ba7d98 --- /dev/null +++ b/migrations/1789325478.sh @@ -0,0 +1,44 @@ +echo "Install the Omarchy kernel and make it the first Limine boot entry" + +# linux-omarchy is an x86_64 kernel. T2 Macs must keep their specialized kernel, +# including when other kernels are installed or the running T2 package is gone. +[[ $(uname -m) == "x86_64" ]] || exit 0 +running_kernel=$(uname -r) +if omarchy-pkg-present linux-t2 || [[ ${running_kernel,,} == *-t2* ]]; then + exit 0 +fi + +limine_conf="${OMARCHY_KERNEL_LIMINE_CONF:-/etc/default/limine}" +rebuild_marker="${OMARCHY_KERNEL_REBUILD_MARKER:-/var/lib/omarchy/migrations/1789325478}" +kernel="linux-omarchy" + +# Completion is machine-wide even though migrations run once per user. Leave +# the old kernel installed so it remains available if the new one cannot boot. +# The new filename and marker also reach users who completed 1789095456. +[[ ! -e $rebuild_marker ]] || exit 0 +omarchy-pkg-add "$kernel" "$kernel-headers" + +# /etc/default/limine has priority over every drop-in, including old Dell +# settings and customized package files whose updates landed in a .pacnew. +# Set the exact kernel first: linux-omarchy-* only matches its older variants. +# Preserve unrelated settings, especially the root filesystem's kernel cmdline. +sudo mkdir -p "$(dirname "$limine_conf")" +sudo touch "$limine_conf" +sudo sed -i -E '/^[[:space:]]*BOOT_ORDER[[:space:]]*=/d' "$limine_conf" +printf '\n%s\n' 'BOOT_ORDER="linux-omarchy, linux-omarchy-*, *, *fallback, Snapshots"' | sudo tee -a "$limine_conf" >/dev/null + +# Package hooks ran before the config repair. Rebuild the new kernel's image +# and boot entry explicitly, including on retries after a failed rebuild. +sudo limine-mkinitcpio "$kernel" + +# limine-mkinitcpio can return success after skipping a failed kernel build. +# Do not mark the migration complete unless the new kernel is in the menu. +if ! sudo limine-entry-tool --tree | grep -E "(^|[^[:alnum:]_-])$kernel([^[:alnum:]_-]|$)" >/dev/null; then + echo "The Omarchy kernel has no Limine boot entry; rerun omarchy-migrate after fixing the boot image build." >&2 + exit 1 +fi + +# Keeping the running kernel installed prevents the updater from detecting a +# kernel replacement, so request the reboot explicitly. +omarchy-state set reboot-required +sudo install -Dm644 /dev/null "$rebuild_marker" diff --git a/test/shell.d/limine-defaults-test.sh b/test/shell.d/limine-defaults-test.sh index cf93ebdf..21adc94f 100644 --- a/test/shell.d/limine-defaults-test.sh +++ b/test/shell.d/limine-defaults-test.sh @@ -10,6 +10,6 @@ grep -Fq 'KERNEL_CMDLINE[default]+=" initramfs_async=0"' "$packaged_defaults" || fail "the packaged Limine defaults still unpack the initramfs synchronously" pass "packaged Limine defaults keep Plymouth alive at the LUKS prompt" -grep -Fxq 'BOOT_ORDER="linux-omarchy-*, *, *fallback, Snapshots"' "$packaged_defaults" || - fail "packaged Limine defaults prefer Omarchy kernels and retain recovery entries" -pass "packaged Limine defaults prefer Omarchy kernels and retain recovery entries" +grep -Fxq 'BOOT_ORDER="linux-t2, linux-omarchy, linux-omarchy-*, *, *fallback, Snapshots"' "$packaged_defaults" || + fail "packaged Limine defaults protect T2 Macs and prefer the exact Omarchy kernel elsewhere" +pass "packaged Limine defaults protect T2 Macs and prefer the exact Omarchy kernel elsewhere" diff --git a/test/shell.d/omarchy-kernel-install-test.sh b/test/shell.d/omarchy-kernel-install-test.sh new file mode 100644 index 00000000..cee20d9c --- /dev/null +++ b/test/shell.d/omarchy-kernel-install-test.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +leaf="$ROOT/install/hardware/kernel.sh" +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT +mkdir -p "$scratch/bin" +export PATH="$scratch/bin:$PATH" CALL_LOG="$scratch/calls" + +cat > "$scratch/bin/uname" <<'SH' +#!/bin/bash +[[ $* == "-m" ]] || exit 99 +printf '%s\n' "${TEST_ARCH:-x86_64}" +SH + +cat > "$scratch/bin/omarchy-pkg-present" <<'SH' +#!/bin/bash +[[ $* == "linux-t2" ]] || exit 99 +[[ ${T2_INSTALLED:-0} == "1" ]] +SH + +cat > "$scratch/bin/lspci" <<'SH' +#!/bin/bash +[[ $* == "-nn" ]] || exit 99 +[[ ${PCI_FAIL:-0} == "0" ]] || exit 1 +printf '%s\n' "${PCI_DEVICES:-00:02.0 VGA compatible controller: Intel [8086:1234]}" +SH + +cat > "$scratch/bin/omarchy-pkg-add" <<'SH' +#!/bin/bash +printf 'add %s\n' "$*" >> "$CALL_LOG" +[[ ${INSTALL_FAIL:-0} == "0" ]] +SH +chmod +x "$scratch/bin/"* + +run_leaf() { + : > "$CALL_LOG" + bash -euo pipefail "$leaf" > "$scratch/output" 2>&1 +} + +run_leaf +grep -Fxq 'add linux-omarchy linux-omarchy-headers' "$CALL_LOG" || fail "ordinary systems install the generic kernel and headers" +pass "ordinary x86_64 installs receive the Omarchy kernel without a Panther Lake hardware gate" + +T2_INSTALLED=1 run_leaf +[[ ! -s $CALL_LOG ]] || fail "an installed T2 kernel excludes the system" +pass "hardware setup skips systems with linux-t2 installed" + +for device in 1801 1802; do + PCI_DEVICES="04:00.0 Mass storage controller: Apple Inc. T2 [106b:$device]" run_leaf + [[ ! -s $CALL_LOG ]] || fail "fresh T2 hardware must not install the generic kernel" +done +pass "fresh T2 hardware is excluded before its specialized kernel has been installed" + +TEST_ARCH=aarch64 run_leaf +[[ ! -s $CALL_LOG ]] || fail "ARM hardware cannot install an x86_64 kernel" +pass "ARM hardware is excluded" + +if PCI_FAIL=1 run_leaf; then + fail "failed T2 hardware detection must stop kernel setup" +fi +[[ ! -s $CALL_LOG ]] || fail "unidentified hardware must not install the generic kernel" +pass "failed PCI detection prevents installing the generic kernel on unidentified hardware" + +if INSTALL_FAIL=1 run_leaf; then + fail "package installation failure must fail kernel setup" +fi +pass "kernel package installation failures stop hardware setup" + +kernel_line=$(grep -n 'hardware/kernel.sh' "$ROOT/install/hardware/all.sh" | cut -d: -f1) +for script in nvidia.sh intel/ipu7-camera.sh fix-elgato-camlink-4k.sh; do + driver_line=$(grep -n "hardware/$script" "$ROOT/install/hardware/all.sh" | cut -d: -f1) + (( kernel_line < driver_line )) || fail "the generic kernel is installed before $script pulls in DKMS drivers" +done +pass "kernel setup precedes NVIDIA, IPU7, and Cam Link DKMS setup" + +for package in linux-omarchy linux-omarchy-headers; do + grep -Fxq "$package" "$ROOT/install/omarchy-other.packages" || fail "the ISO includes $package" +done +! grep -q 'linux-omarchy-ptl-novrr-mm' "$ROOT/install/omarchy-other.packages" || fail "the ISO must not pull in the retired PTL variant" +pass "the ISO package list includes the generic kernel and headers" diff --git a/test/shell.d/omarchy-kernel-migration-test.sh b/test/shell.d/omarchy-kernel-migration-test.sh new file mode 100644 index 00000000..24e3480b --- /dev/null +++ b/test/shell.d/omarchy-kernel-migration-test.sh @@ -0,0 +1,248 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +migration="$ROOT/migrations/1789325478.sh" +scratch=$(mktemp -d) +trap 'rm -rf "$scratch"' EXIT +mkdir -p "$scratch/bin" "$scratch/drop-ins" + +export PATH="$scratch/bin:$ROOT/bin:$PATH" +export CALL_LOG="$scratch/calls" +export INSTALLED_PACKAGES="$scratch/packages" +export OMARCHY_KERNEL_LIMINE_CONF="$scratch/limine" +export OMARCHY_KERNEL_LIMINE_DROP_INS="$scratch/drop-ins" +export OMARCHY_KERNEL_REBUILD_MARKER="$scratch/state/1789325478" +kernel="linux-omarchy" +boot_order='BOOT_ORDER="linux-omarchy, linux-omarchy-*, *, *fallback, Snapshots"' + +# Exercise the real package helpers, including their post-install queries. +cat > "$scratch/bin/pacman" <<'SH' +#!/bin/bash +case "$1" in + -Q) grep -Fxq "$2" "$INSTALLED_PACKAGES" ;; + -S) + printf 'pacman %s\n' "$*" >> "$CALL_LOG" + [[ ${INSTALL_FAIL:-0} == "0" ]] || exit 1 + for arg in "$@"; do + [[ $arg == -* ]] || printf '%s\n' "$arg" >> "$INSTALLED_PACKAGES" + done + ;; + *) exit 99 ;; +esac +SH + +cat > "$scratch/bin/sudo" <<'SH' +#!/bin/bash +printf 'sudo %s\n' "$*" >> "$CALL_LOG" +case "$1" in + pacman | mkdir | touch | sed | tee | install | limine-mkinitcpio | limine-entry-tool) exec "$@" ;; + *) exit 99 ;; +esac +SH + +cat > "$scratch/bin/limine-mkinitcpio" <<'SH' +#!/bin/bash +[[ ${REBUILD_FAIL:-0} == "0" ]] +SH + +cat > "$scratch/bin/limine-entry-tool" <<'SH' +#!/bin/bash +[[ $* == "--tree" ]] || exit 99 +printf '%s\n' 'Omarchy' ' linux-ptl' ' linux-omarchy-ptl-novrr-mm' ' linux-omarchy-bore' ' linux-omarchy-fallback' ' Snapshots' +if [[ ${MISSING_ENTRY:-0} == "0" ]]; then + printf '%s\n' ' linux-omarchy' +fi +SH + +cat > "$scratch/bin/omarchy-state" <<'SH' +#!/bin/bash +[[ $* == "set reboot-required" ]] || exit 99 +printf 'state %s\n' "$*" >> "$CALL_LOG" +SH + +cat > "$scratch/bin/uname" <<'SH' +#!/bin/bash +case "$1" in + -m) printf '%s\n' "${TEST_ARCH:-x86_64}" ;; + -r) printf '%s\n' "${TEST_KERNEL_RELEASE:-7.2.5-arch1-1}" ;; + *) exit 99 ;; +esac +SH + +cat > "$scratch/bin/omarchy-notification-dismiss" <<'SH' +#!/bin/bash +exit 0 +SH +chmod +x "$scratch/bin/"* + +reset_fixture() { + : > "$CALL_LOG" + printf '%s\n' linux-ptl linux-ptl-headers > "$INSTALLED_PACKAGES" + rm -f "$OMARCHY_KERNEL_REBUILD_MARKER" "$OMARCHY_KERNEL_LIMINE_DROP_INS/"* + cat > "$OMARCHY_KERNEL_LIMINE_CONF" <<'CONF' +KERNEL_CMDLINE[default]="root=UUID=keep-me rw cryptdevice=UUID=keep-me:root" +BOOT_ORDER="*, *fallback, Snapshots" +ENABLE_UKI=yes +CONF + cp "$OMARCHY_KERNEL_LIMINE_CONF" "$scratch/original-limine" +} + +run_migration() { + bash -euo pipefail "$migration" > "$scratch/output" 2>&1 +} + +assert_preferred() { + grep -Fxq "$boot_order" "$1" || fail "Omarchy kernels are preferred in $1" +} + +assert_skipped() { + [[ ! -s $CALL_LOG ]] || fail "excluded systems do not change" "$(<"$CALL_LOG")" + cmp -s "$OMARCHY_KERNEL_LIMINE_CONF" "$scratch/original-limine" || fail "excluded systems keep their boot settings" + [[ ! -e $OMARCHY_KERNEL_REBUILD_MARKER ]] || fail "excluded systems do not get a completion marker" +} + +for old_kernel in linux linux-lts linux-zen linux-ptl linux-omarchy-ptl-novrr-mm; do + reset_fixture + printf '%s\n' "$old_kernel" "$old_kernel-headers" > "$INSTALLED_PACKAGES" + run_migration + grep -Fxq "$kernel" "$INSTALLED_PACKAGES" || fail "the generic kernel is installed" + grep -Fxq "$kernel-headers" "$INSTALLED_PACKAGES" || fail "its headers are installed" + grep -Fxq "$old_kernel" "$INSTALLED_PACKAGES" || fail "the previous kernel remains available" + assert_preferred "$OMARCHY_KERNEL_LIMINE_CONF" + pass "$old_kernel systems receive the generic Omarchy kernel and retain their recovery kernel" +done + +for installed in linux-t2 $'linux-t2\nlinux\nlinux-ptl\nlinux-omarchy'; do + reset_fixture + printf '%s\n' "$installed" > "$INSTALLED_PACKAGES" + run_migration + assert_skipped +done +pass "linux-t2 systems are skipped even with other kernels installed" + +reset_fixture +TEST_KERNEL_RELEASE=7.2.5-arch1-T2 run_migration +assert_skipped +pass "a running T2 kernel is excluded even if its package is no longer installed" + +reset_fixture +TEST_ARCH=aarch64 run_migration +assert_skipped +pass "ARM systems cannot receive an x86_64 kernel" + +reset_fixture +printf '%s\n' linux-omarchy-ptl-novrr-mm > "$INSTALLED_PACKAGES" +mkdir -p "$scratch/state" "$scratch/user-markers" "$scratch/omarchy/migrations" +export OMARCHY_PTL_REBUILD_MARKER="$scratch/state/1789095456" +export OMARCHY_MIGRATION_STATE="$scratch/user-markers" +touch "$OMARCHY_PTL_REBUILD_MARKER" "$OMARCHY_MIGRATION_STATE/1789095456.sh" +cp "$migration" "$scratch/omarchy/migrations/" +[[ ! -e $ROOT/migrations/1789095456.sh ]] || fail "the superseded migration must not install the PTL variant first" +pending=$(OMARCHY_PATH="$scratch/omarchy" "$ROOT/bin/omarchy-migrate" --pending) +[[ $pending == "1789325478.sh" ]] || fail "the renamed migration is pending after completing the old migration" +OMARCHY_PATH="$scratch/omarchy" "$ROOT/bin/omarchy-migrate" > "$scratch/output" 2>&1 +grep -Fxq "$kernel" "$INSTALLED_PACKAGES" || fail "the old completion markers cannot skip the generic kernel" +[[ -f $OMARCHY_KERNEL_REBUILD_MARKER && -f $OMARCHY_MIGRATION_STATE/1789325478.sh ]] || fail "new machine and user completion markers are recorded" +assert_preferred "$OMARCHY_KERNEL_LIMINE_CONF" +pass "users who completed the PTL migration run the renamed migration with fresh completion markers" + +reset_fixture +for name in dell-xps-panther-lake zz-dell-xps-panther-lake; do + printf '%s\n' 'BOOT_ORDER="linux-ptl*, *fallback, Snapshots"' > "$OMARCHY_KERNEL_LIMINE_DROP_INS/$name.conf" +done +cp "$OMARCHY_KERNEL_LIMINE_CONF" "$OMARCHY_KERNEL_LIMINE_DROP_INS/omarchy-defaults.conf" +run_migration +grep -Fxq "pacman -S --noconfirm --needed $kernel $kernel-headers" "$CALL_LOG" || fail "both new packages are installed" +grep -Fxq linux-ptl "$INSTALLED_PACKAGES" || fail "the old kernel is kept for recovery" +grep -Fxq linux-ptl-headers "$INSTALLED_PACKAGES" || fail "the old kernel headers are kept" +assert_preferred "$OMARCHY_KERNEL_LIMINE_CONF" +diff -u <(sed '/^BOOT_ORDER=/d; /^$/d' "$scratch/original-limine") \ + <(sed '/^BOOT_ORDER=/d; /^$/d' "$OMARCHY_KERNEL_LIMINE_CONF") || fail "kernel command line and unrelated settings are preserved" +grep -Fxq "sudo limine-mkinitcpio $kernel" "$CALL_LOG" || fail "the new kernel's boot image is rebuilt" +[[ -f $OMARCHY_KERNEL_REBUILD_MARKER ]] || fail "successful completion is recorded" +grep -Fxq 'state set reboot-required' "$CALL_LOG" || fail "the updater must offer a reboot when retaining the old kernel" +pass "new packages install, the central boot order is set, and the old kernel remains available" + +: > "$CALL_LOG" +run_migration +[[ ! -s $CALL_LOG ]] || fail "another user's run does not repeat the machine-wide migration" +pass "repeat runs are a no-op after successful completion" + +reset_fixture +printf '%s\n' "$kernel" >> "$INSTALLED_PACKAGES" +run_migration +grep -Fxq "$kernel-headers" "$INSTALLED_PACKAGES" || fail "missing headers install when the kernel is already present" +pass "a partially installed kernel gets its missing headers" + +reset_fixture +printf '%s\n' "$kernel" "$kernel-headers" >> "$INSTALLED_PACKAGES" +run_migration +! grep -q '^pacman -S' "$CALL_LOG" || fail "already installed packages are not reinstalled" +assert_preferred "$OMARCHY_KERNEL_LIMINE_CONF" +pass "existing new packages still receive the config repair and boot rebuild" + +reset_fixture +if INSTALL_FAIL=1 run_migration; then + fail "package installation failure must fail the migration" +fi +cmp -s "$OMARCHY_KERNEL_LIMINE_CONF" "$scratch/original-limine" || fail "install failure leaves the config untouched" +! grep -q 'limine-mkinitcpio' "$CALL_LOG" || fail "install failure does not rebuild" +[[ ! -e $OMARCHY_KERNEL_REBUILD_MARKER ]] || fail "install failure stays pending" +pass "package failures leave the old boot setup intact and the migration pending" + +reset_fixture +if REBUILD_FAIL=1 run_migration; then + fail "boot rebuild failure must fail the migration" +fi +[[ ! -e $OMARCHY_KERNEL_REBUILD_MARKER ]] || fail "rebuild failure stays pending" +! grep -q '^state ' "$CALL_LOG" || fail "rebuild failure must not request a reboot" +assert_preferred "$OMARCHY_KERNEL_LIMINE_CONF" +: > "$CALL_LOG" +run_migration +grep -Fxq "sudo limine-mkinitcpio $kernel" "$CALL_LOG" || fail "retry must rebuild even after config and packages are repaired" +[[ -f $OMARCHY_KERNEL_REBUILD_MARKER ]] || fail "retry records successful completion" +pass "a failed rebuild is retried even after config and package changes succeeded" + +reset_fixture +if MISSING_ENTRY=1 run_migration; then + fail "a silently skipped kernel build must fail the migration" +fi +[[ ! -e $OMARCHY_KERNEL_REBUILD_MARKER ]] || fail "a missing boot entry stays pending" +! grep -q '^state ' "$CALL_LOG" || fail "a missing boot entry must not request a reboot" +run_migration +[[ -f $OMARCHY_KERNEL_REBUILD_MARKER ]] || fail "a missing boot entry can be repaired on retry" +pass "older Omarchy variants and fallback entries cannot satisfy generic kernel verification" + +reset_fixture +cat >> "$OMARCHY_KERNEL_LIMINE_CONF" <<'CONF' +BOOT_ORDER="linux-omarchy-*, *, *fallback, Snapshots" + BOOT_ORDER = 'linux-lts, *, *fallback, Snapshots' +CONF +run_migration +assert_preferred "$OMARCHY_KERNEL_LIMINE_CONF" +[[ $(grep -c '^BOOT_ORDER=' "$OMARCHY_KERNEL_LIMINE_CONF") == "1" ]] || fail "only one boot order is written" +! grep -Eq '^[[:space:]]+BOOT_ORDER' "$OMARCHY_KERNEL_LIMINE_CONF" || fail "a later custom assignment cannot override first position" +pass "the exact generic kernel takes first position over previous PTL and custom orders" + +reset_fixture +for name in omarchy-defaults dell-xps-panther-lake zz-dell-xps-panther-lake; do + printf '%s\n' 'BOOT_ORDER="linux-ptl*, *fallback, Snapshots"' > "$OMARCHY_KERNEL_LIMINE_DROP_INS/$name.conf" +done +printf '%s\n' 'ENABLE_SORT=yes' >> "$OMARCHY_KERNEL_LIMINE_DROP_INS/omarchy-defaults.conf" +cp "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" "$OMARCHY_KERNEL_LIMINE_DROP_INS/omarchy-defaults.conf.pacnew" +run_migration +effective_order=$(bash -c 'declare -A KERNEL_CMDLINE; for conf in "$1/"*.conf "$2"; do source "$conf"; done; printf "%s" "$BOOT_ORDER"' -- "$OMARCHY_KERNEL_LIMINE_DROP_INS" "$OMARCHY_KERNEL_LIMINE_CONF") +[[ $effective_order == "linux-omarchy, linux-omarchy-*, *, *fallback, Snapshots" ]] || fail "the central config wins over old and customized drop-ins" +grep -Fxq 'ENABLE_SORT=yes' "$OMARCHY_KERNEL_LIMINE_DROP_INS/omarchy-defaults.conf" || fail "custom packaged settings survive" +cmp -s "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" \ + "$OMARCHY_KERNEL_LIMINE_DROP_INS/omarchy-defaults.conf.pacnew" || fail "the pacnew is left for the administrator to merge" +pass "the central boot order overrides legacy drop-ins and pacnew files without rewriting them" + +reset_fixture +rm "$OMARCHY_KERNEL_LIMINE_CONF" +run_migration +assert_preferred "$OMARCHY_KERNEL_LIMINE_CONF" +pass "a missing central config is created with the exact kernel first" diff --git a/test/shell.d/ptl-kernel-migration-test.sh b/test/shell.d/ptl-kernel-migration-test.sh deleted file mode 100644 index 874397c0..00000000 --- a/test/shell.d/ptl-kernel-migration-test.sh +++ /dev/null @@ -1,195 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" - -migration="$ROOT/migrations/1789095456.sh" -scratch=$(mktemp -d) -trap 'rm -rf "$scratch"' EXIT -mkdir -p "$scratch/bin" "$scratch/drop-ins" - -export PATH="$scratch/bin:$ROOT/bin:$PATH" -export CALL_LOG="$scratch/calls" -export INSTALLED_PACKAGES="$scratch/packages" -export OMARCHY_PTL_LIMINE_CONF="$scratch/limine" -export OMARCHY_PTL_LIMINE_DROP_INS="$scratch/drop-ins" -export OMARCHY_PTL_REBUILD_MARKER="$scratch/state/completed" -kernel="linux-omarchy-ptl-novrr-mm" -boot_order='BOOT_ORDER="linux-omarchy-*, *, *fallback, Snapshots"' - -# Exercise the real package helpers, including their post-install queries. -cat > "$scratch/bin/pacman" <<'SH' -#!/bin/bash -case "$1" in - -Q) grep -Fxq "$2" "$INSTALLED_PACKAGES" ;; - -S) - printf 'pacman %s\n' "$*" >> "$CALL_LOG" - [[ ${INSTALL_FAIL:-0} == "0" ]] || exit 1 - for arg in "$@"; do - [[ $arg == -* ]] || printf '%s\n' "$arg" >> "$INSTALLED_PACKAGES" - done - ;; - *) exit 99 ;; -esac -SH - -cat > "$scratch/bin/sudo" <<'SH' -#!/bin/bash -printf 'sudo %s\n' "$*" >> "$CALL_LOG" -case "$1" in - pacman | sed | install | limine-mkinitcpio | limine-entry-tool) exec "$@" ;; - *) exit 99 ;; -esac -SH - -cat > "$scratch/bin/limine-mkinitcpio" <<'SH' -#!/bin/bash -[[ ${REBUILD_FAIL:-0} == "0" ]] -SH - -cat > "$scratch/bin/limine-entry-tool" <<'SH' -#!/bin/bash -[[ $* == "--tree" ]] || exit 99 -printf '%s\n' 'Omarchy' ' linux-ptl' ' Snapshots' -if [[ ${MISSING_ENTRY:-0} == "0" ]]; then - printf '%s\n' ' linux-omarchy-ptl-novrr-mm' -fi -SH - -cat > "$scratch/bin/omarchy-state" <<'SH' -#!/bin/bash -[[ $* == "set reboot-required" ]] || exit 99 -printf 'state %s\n' "$*" >> "$CALL_LOG" -SH -chmod +x "$scratch/bin/"* - -reset_fixture() { - : > "$CALL_LOG" - printf '%s\n' linux-ptl linux-ptl-headers > "$INSTALLED_PACKAGES" - rm -f "$OMARCHY_PTL_REBUILD_MARKER" "$OMARCHY_PTL_LIMINE_DROP_INS/"*.conf - cat > "$OMARCHY_PTL_LIMINE_CONF" <<'CONF' -KERNEL_CMDLINE[default]="root=UUID=keep-me rw cryptdevice=UUID=keep-me:root" -BOOT_ORDER="*, *fallback, Snapshots" -ENABLE_UKI=yes -CONF - cp "$OMARCHY_PTL_LIMINE_CONF" "$scratch/original-limine" -} - -run_migration() { - bash -euo pipefail "$migration" > "$scratch/output" 2>&1 -} - -assert_preferred() { - grep -Fxq "$boot_order" "$1" || fail "Omarchy kernels are preferred in $1" -} - -reset_fixture -printf '%s\n' linux > "$INSTALLED_PACKAGES" -run_migration -[[ ! -s $CALL_LOG ]] || fail "systems without linux-ptl do not change" -cmp -s "$OMARCHY_PTL_LIMINE_CONF" "$scratch/original-limine" || fail "unaffected Limine settings stay unchanged" -[[ ! -e $OMARCHY_PTL_REBUILD_MARKER ]] || fail "unaffected systems do not get a completion marker" -pass "systems without linux-ptl are skipped" - -reset_fixture -for name in dell-xps-panther-lake zz-dell-xps-panther-lake; do - printf '%s\n' 'BOOT_ORDER="linux-ptl*, *fallback, Snapshots"' > "$OMARCHY_PTL_LIMINE_DROP_INS/$name.conf" -done -cp "$OMARCHY_PTL_LIMINE_CONF" "$OMARCHY_PTL_LIMINE_DROP_INS/omarchy-defaults.conf" -run_migration -grep -Fxq "pacman -S --noconfirm --needed $kernel $kernel-headers" "$CALL_LOG" || fail "both new packages are installed" -grep -Fxq linux-ptl "$INSTALLED_PACKAGES" || fail "the old kernel is kept for recovery" -grep -Fxq linux-ptl-headers "$INSTALLED_PACKAGES" || fail "the old kernel headers are kept" -assert_preferred "$OMARCHY_PTL_LIMINE_CONF" -for conf in "$OMARCHY_PTL_LIMINE_DROP_INS/"*.conf; do - assert_preferred "$conf" -done -diff -u <(sed '/^BOOT_ORDER=/d' "$scratch/original-limine") \ - <(sed '/^BOOT_ORDER=/d' "$OMARCHY_PTL_LIMINE_CONF") || fail "kernel command line and unrelated settings are preserved" -grep -Fxq "sudo limine-mkinitcpio $kernel" "$CALL_LOG" || fail "the new kernel's boot image is rebuilt" -[[ -f $OMARCHY_PTL_REBUILD_MARKER ]] || fail "successful completion is recorded" -grep -Fxq 'state set reboot-required' "$CALL_LOG" || fail "the updater must offer a reboot when retaining the old kernel" -pass "new packages install, all stock boot orders are repaired, and the old kernel remains available" - -: > "$CALL_LOG" -run_migration -[[ ! -s $CALL_LOG ]] || fail "another user's run does not repeat the machine-wide migration" -pass "repeat runs are a no-op after successful completion" - -reset_fixture -printf '%s\n' "$kernel" >> "$INSTALLED_PACKAGES" -run_migration -grep -Fxq "$kernel-headers" "$INSTALLED_PACKAGES" || fail "missing headers install when the kernel is already present" -pass "a partially installed kernel gets its missing headers" - -reset_fixture -printf '%s\n' "$kernel" "$kernel-headers" >> "$INSTALLED_PACKAGES" -run_migration -! grep -q '^pacman -S' "$CALL_LOG" || fail "already installed packages are not reinstalled" -assert_preferred "$OMARCHY_PTL_LIMINE_CONF" -pass "existing new packages still receive the config repair and boot rebuild" - -reset_fixture -if INSTALL_FAIL=1 run_migration; then - fail "package installation failure must fail the migration" -fi -cmp -s "$OMARCHY_PTL_LIMINE_CONF" "$scratch/original-limine" || fail "install failure leaves the config untouched" -! grep -q 'limine-mkinitcpio' "$CALL_LOG" || fail "install failure does not rebuild" -[[ ! -e $OMARCHY_PTL_REBUILD_MARKER ]] || fail "install failure stays pending" -pass "package failures leave the old boot setup intact and the migration pending" - -reset_fixture -if REBUILD_FAIL=1 run_migration; then - fail "boot rebuild failure must fail the migration" -fi -[[ ! -e $OMARCHY_PTL_REBUILD_MARKER ]] || fail "rebuild failure stays pending" -! grep -q '^state ' "$CALL_LOG" || fail "rebuild failure must not request a reboot" -assert_preferred "$OMARCHY_PTL_LIMINE_CONF" -: > "$CALL_LOG" -run_migration -grep -Fxq "sudo limine-mkinitcpio $kernel" "$CALL_LOG" || fail "retry must rebuild even after config and packages are repaired" -[[ -f $OMARCHY_PTL_REBUILD_MARKER ]] || fail "retry records successful completion" -pass "a failed rebuild is retried even after config and package changes succeeded" - -reset_fixture -if MISSING_ENTRY=1 run_migration; then - fail "a silently skipped kernel build must fail the migration" -fi -[[ ! -e $OMARCHY_PTL_REBUILD_MARKER ]] || fail "a missing boot entry stays pending" -! grep -q '^state ' "$CALL_LOG" || fail "a missing boot entry must not request a reboot" -run_migration -[[ -f $OMARCHY_PTL_REBUILD_MARKER ]] || fail "a missing boot entry can be repaired on retry" -pass "a successful command without a new boot entry cannot complete the migration" - -reset_fixture -printf '%s\n' 'BOOT_ORDER="linux-lts, *, *fallback, Snapshots"' > "$OMARCHY_PTL_LIMINE_CONF" -cp "$OMARCHY_PTL_LIMINE_CONF" "$scratch/custom-limine" -run_migration -cmp -s "$OMARCHY_PTL_LIMINE_CONF" "$scratch/custom-limine" || fail "custom boot orders stay unchanged" -pass "administrator-defined boot orders are preserved" - -reset_fixture -cp "$OMARCHY_PTL_LIMINE_CONF" "$OMARCHY_PTL_LIMINE_DROP_INS/omarchy-defaults.conf" -printf '%s\n' 'ENABLE_SORT=yes' >> "$OMARCHY_PTL_LIMINE_DROP_INS/omarchy-defaults.conf" -cp "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" "$OMARCHY_PTL_LIMINE_DROP_INS/omarchy-defaults.conf.pacnew" -run_migration -assert_preferred "$OMARCHY_PTL_LIMINE_DROP_INS/omarchy-defaults.conf" -grep -Fxq 'ENABLE_SORT=yes' "$OMARCHY_PTL_LIMINE_DROP_INS/omarchy-defaults.conf" || fail "custom packaged settings survive the repair" -cmp -s "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" \ - "$OMARCHY_PTL_LIMINE_DROP_INS/omarchy-defaults.conf.pacnew" || fail "the pending pacnew is left for the administrator to merge" -pass "active packaged defaults are repaired when customized settings leave the update in a pacnew" - -reset_fixture -printf '%s\n' ' BOOT_ORDER="*, *fallback, Snapshots" # keep this comment' > "$OMARCHY_PTL_LIMINE_CONF" -run_migration -grep -Fxq " $boot_order # keep this comment" "$OMARCHY_PTL_LIMINE_CONF" || fail "stock orders with comments are repaired" -pass "indentation and comments survive the repair" - -reset_fixture -rm "$OMARCHY_PTL_LIMINE_CONF" -cp "$ROOT/etc/limine-entry-tool.d/omarchy-defaults.conf" "$OMARCHY_PTL_LIMINE_DROP_INS/omarchy-defaults.conf" -run_migration -[[ ! -e $OMARCHY_PTL_LIMINE_CONF ]] || fail "missing central config is not fabricated" -assert_preferred "$OMARCHY_PTL_LIMINE_DROP_INS/omarchy-defaults.conf" -pass "installs without a central config inherit the packaged boot order" diff --git a/test/shell.d/xps13-sidecar-amps-test.sh b/test/shell.d/xps13-sidecar-amps-test.sh index d82498f9..b7935fb2 100755 --- a/test/shell.d/xps13-sidecar-amps-test.sh +++ b/test/shell.d/xps13-sidecar-amps-test.sh @@ -13,13 +13,12 @@ grep -q 'run_logged .*hardware/dell-xps13-sidecar-amps.sh' "$all" || fail "the sidecar amplifier workaround runs during hardware setup" pass "the sidecar amplifier workaround runs during hardware setup" -# The apply step rebuilds the boot image, so it has to see the Panther Lake -# kernel that ptl-kernel.sh swaps in rather than the stock one it replaces. -ptl_line=$(grep -n 'hardware/intel/ptl-kernel.sh' "$all" | cut -d: -f1) +# The apply step rebuilds the boot image, so the default kernel must be installed. +kernel_line=$(grep -n 'hardware/kernel.sh' "$all" | cut -d: -f1) amps_line=$(grep -n 'hardware/dell-xps13-sidecar-amps.sh' "$all" | cut -d: -f1) -((ptl_line < amps_line)) || - fail "the sidecar amplifier workaround runs after the Panther Lake kernel swap" -pass "the sidecar amplifier workaround runs after the Panther Lake kernel swap" +((kernel_line < amps_line)) || + fail "the sidecar amplifier workaround runs after the default kernel is installed" +pass "the sidecar amplifier workaround runs after the default kernel is installed" [[ -n $migration ]] || fail "a migration enables the workaround on existing installs" pass "a migration enables the workaround on existing installs" From 08a875852e6233b973de5041dc04d8ccbdfbb69f Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Mon, 14 Sep 2026 16:46:45 -0400 Subject: [PATCH 39/48] Leave fresh-install kernel selection to the ISO --- install/hardware/all.sh | 4 +- install/hardware/kernel.sh | 9 --- test/shell.d/omarchy-kernel-install-test.sh | 84 --------------------- test/shell.d/xps13-sidecar-amps-test.sh | 7 -- 4 files changed, 1 insertion(+), 103 deletions(-) delete mode 100644 install/hardware/kernel.sh delete mode 100644 test/shell.d/omarchy-kernel-install-test.sh diff --git a/install/hardware/all.sh b/install/hardware/all.sh index d40069b2..12add110 100644 --- a/install/hardware/all.sh +++ b/install/hardware/all.sh @@ -1,5 +1,3 @@ -run_logged "$OMARCHY_INSTALL/hardware/kernel.sh" - run_logged "$OMARCHY_INSTALL/hardware/asus-rog.sh" run_logged "$OMARCHY_INSTALL/hardware/framework16.sh" run_logged "$OMARCHY_INSTALL/hardware/dell-xps-touchpad-haptics.sh" @@ -23,7 +21,7 @@ run_logged "$OMARCHY_INSTALL/hardware/intel/sof-firmware.sh" run_logged "$OMARCHY_INSTALL/hardware/fix-elgato-camlink-4k.sh" -# Rebuilds the boot image, so it follows kernel and camera module setup. +# Rebuilds the boot image, so it follows camera module setup. run_logged "$OMARCHY_INSTALL/hardware/dell-xps13-sidecar-amps.sh" run_logged "$OMARCHY_INSTALL/hardware/asus/fix-asus-ptl-display-backlight.sh" diff --git a/install/hardware/kernel.sh b/install/hardware/kernel.sh deleted file mode 100644 index ed259c66..00000000 --- a/install/hardware/kernel.sh +++ /dev/null @@ -1,9 +0,0 @@ -# Install the default kernel before hardware setup pulls in DKMS modules. -# On a fresh T2 install linux-t2 is not installed yet, so also check the chip. -if [[ $(uname -m) == "x86_64" ]] && ! omarchy-pkg-present linux-t2; then - pci_devices=$(lspci -nn) - if ! grep "106b:180[12]" <<< "$pci_devices" >/dev/null; then - echo "Installing the Omarchy kernel..." - omarchy-pkg-add linux-omarchy linux-omarchy-headers - fi -fi diff --git a/test/shell.d/omarchy-kernel-install-test.sh b/test/shell.d/omarchy-kernel-install-test.sh deleted file mode 100644 index cee20d9c..00000000 --- a/test/shell.d/omarchy-kernel-install-test.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" - -leaf="$ROOT/install/hardware/kernel.sh" -scratch=$(mktemp -d) -trap 'rm -rf "$scratch"' EXIT -mkdir -p "$scratch/bin" -export PATH="$scratch/bin:$PATH" CALL_LOG="$scratch/calls" - -cat > "$scratch/bin/uname" <<'SH' -#!/bin/bash -[[ $* == "-m" ]] || exit 99 -printf '%s\n' "${TEST_ARCH:-x86_64}" -SH - -cat > "$scratch/bin/omarchy-pkg-present" <<'SH' -#!/bin/bash -[[ $* == "linux-t2" ]] || exit 99 -[[ ${T2_INSTALLED:-0} == "1" ]] -SH - -cat > "$scratch/bin/lspci" <<'SH' -#!/bin/bash -[[ $* == "-nn" ]] || exit 99 -[[ ${PCI_FAIL:-0} == "0" ]] || exit 1 -printf '%s\n' "${PCI_DEVICES:-00:02.0 VGA compatible controller: Intel [8086:1234]}" -SH - -cat > "$scratch/bin/omarchy-pkg-add" <<'SH' -#!/bin/bash -printf 'add %s\n' "$*" >> "$CALL_LOG" -[[ ${INSTALL_FAIL:-0} == "0" ]] -SH -chmod +x "$scratch/bin/"* - -run_leaf() { - : > "$CALL_LOG" - bash -euo pipefail "$leaf" > "$scratch/output" 2>&1 -} - -run_leaf -grep -Fxq 'add linux-omarchy linux-omarchy-headers' "$CALL_LOG" || fail "ordinary systems install the generic kernel and headers" -pass "ordinary x86_64 installs receive the Omarchy kernel without a Panther Lake hardware gate" - -T2_INSTALLED=1 run_leaf -[[ ! -s $CALL_LOG ]] || fail "an installed T2 kernel excludes the system" -pass "hardware setup skips systems with linux-t2 installed" - -for device in 1801 1802; do - PCI_DEVICES="04:00.0 Mass storage controller: Apple Inc. T2 [106b:$device]" run_leaf - [[ ! -s $CALL_LOG ]] || fail "fresh T2 hardware must not install the generic kernel" -done -pass "fresh T2 hardware is excluded before its specialized kernel has been installed" - -TEST_ARCH=aarch64 run_leaf -[[ ! -s $CALL_LOG ]] || fail "ARM hardware cannot install an x86_64 kernel" -pass "ARM hardware is excluded" - -if PCI_FAIL=1 run_leaf; then - fail "failed T2 hardware detection must stop kernel setup" -fi -[[ ! -s $CALL_LOG ]] || fail "unidentified hardware must not install the generic kernel" -pass "failed PCI detection prevents installing the generic kernel on unidentified hardware" - -if INSTALL_FAIL=1 run_leaf; then - fail "package installation failure must fail kernel setup" -fi -pass "kernel package installation failures stop hardware setup" - -kernel_line=$(grep -n 'hardware/kernel.sh' "$ROOT/install/hardware/all.sh" | cut -d: -f1) -for script in nvidia.sh intel/ipu7-camera.sh fix-elgato-camlink-4k.sh; do - driver_line=$(grep -n "hardware/$script" "$ROOT/install/hardware/all.sh" | cut -d: -f1) - (( kernel_line < driver_line )) || fail "the generic kernel is installed before $script pulls in DKMS drivers" -done -pass "kernel setup precedes NVIDIA, IPU7, and Cam Link DKMS setup" - -for package in linux-omarchy linux-omarchy-headers; do - grep -Fxq "$package" "$ROOT/install/omarchy-other.packages" || fail "the ISO includes $package" -done -! grep -q 'linux-omarchy-ptl-novrr-mm' "$ROOT/install/omarchy-other.packages" || fail "the ISO must not pull in the retired PTL variant" -pass "the ISO package list includes the generic kernel and headers" diff --git a/test/shell.d/xps13-sidecar-amps-test.sh b/test/shell.d/xps13-sidecar-amps-test.sh index b7935fb2..a8313a48 100755 --- a/test/shell.d/xps13-sidecar-amps-test.sh +++ b/test/shell.d/xps13-sidecar-amps-test.sh @@ -13,13 +13,6 @@ grep -q 'run_logged .*hardware/dell-xps13-sidecar-amps.sh' "$all" || fail "the sidecar amplifier workaround runs during hardware setup" pass "the sidecar amplifier workaround runs during hardware setup" -# The apply step rebuilds the boot image, so the default kernel must be installed. -kernel_line=$(grep -n 'hardware/kernel.sh' "$all" | cut -d: -f1) -amps_line=$(grep -n 'hardware/dell-xps13-sidecar-amps.sh' "$all" | cut -d: -f1) -((kernel_line < amps_line)) || - fail "the sidecar amplifier workaround runs after the default kernel is installed" -pass "the sidecar amplifier workaround runs after the default kernel is installed" - [[ -n $migration ]] || fail "a migration enables the workaround on existing installs" pass "a migration enables the workaround on existing installs" From f464b6087dd4550ed153d940fccd38531a83425b Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Mon, 14 Sep 2026 23:45:34 -0400 Subject: [PATCH 40/48] Use Omarchy kernel headers for hardware DKMS drivers --- install/hardware/fix-bcm43xx.sh | 2 +- install/hardware/fix-tuxedo-backlight.sh | 2 +- install/hardware/fix-yt6801-ethernet-adapter.sh | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/install/hardware/fix-bcm43xx.sh b/install/hardware/fix-bcm43xx.sh index 6b7e5648..94fd16e6 100644 --- a/install/hardware/fix-bcm43xx.sh +++ b/install/hardware/fix-bcm43xx.sh @@ -6,5 +6,5 @@ pci_info=$(lspci -nn) if (echo "$pci_info" | grep -q "14e4:43a0" || echo "$pci_info" | grep -q "14e4:4331"); then echo "BCM4360 / BCM4331 detected" - omarchy-pkg-add broadcom-wl-dkms linux-headers + omarchy-pkg-add broadcom-wl-dkms linux-omarchy-headers fi diff --git a/install/hardware/fix-tuxedo-backlight.sh b/install/hardware/fix-tuxedo-backlight.sh index 9644d310..a2d3b8c6 100644 --- a/install/hardware/fix-tuxedo-backlight.sh +++ b/install/hardware/fix-tuxedo-backlight.sh @@ -1,7 +1,7 @@ # Install Tuxedo drivers for keyboard backlighting on Tuxedo laptops and # compatible devices like the Slimbook Executive (Clevo/Tuxedo chassis). if cat /sys/class/dmi/id/sys_vendor 2>/dev/null | grep -qi "TUXEDO\|Slimbook"; then - omarchy-pkg-add linux-headers tuxedo-drivers-nocompatcheck-dkms + omarchy-pkg-add linux-omarchy-headers tuxedo-drivers-nocompatcheck-dkms # Blacklist the legacy clevo_xsm_wmi module which conflicts with the tuxedo-drivers # clevo_wmi module. When clevo_xsm_wmi loads first, it grabs the Clevo WMI GUIDs, diff --git a/install/hardware/fix-yt6801-ethernet-adapter.sh b/install/hardware/fix-yt6801-ethernet-adapter.sh index 34331a0f..7c0360e6 100644 --- a/install/hardware/fix-yt6801-ethernet-adapter.sh +++ b/install/hardware/fix-yt6801-ethernet-adapter.sh @@ -1,4 +1,4 @@ # Install drivers for Motorcomm YT6801 ethernet adapter used by the Slimbook Executive if lspci | grep -i "YT6801\|Motorcomm.*Ethernet"; then - omarchy-pkg-add linux-headers yt6801-dkms + omarchy-pkg-add linux-omarchy-headers yt6801-dkms fi From 662051ecde66c5d4f9501df1e816ae5136a408cb Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Tue, 15 Sep 2026 00:06:17 -0400 Subject: [PATCH 41/48] Install matching kernel headers for every DKMS setup --- bin/omarchy-install-gaming-xbox-controllers | 3 +- bin/omarchy-pkg-add-kernel-headers | 13 +++ install/hardware/apple/fix-spi-keyboard.sh | 1 + install/hardware/fix-bcm43xx.sh | 3 +- install/hardware/fix-elgato-camlink-4k.sh | 4 +- install/hardware/fix-tuxedo-backlight.sh | 3 +- .../hardware/fix-yt6801-ethernet-adapter.sh | 3 +- install/hardware/intel/ipu7-camera.sh | 1 + install/hardware/nvidia.sh | 5 +- install/omarchy-other.packages | 2 - migrations/1789444024.sh | 8 ++ test/acceptance.d/system-test.sh | 16 +++- test/shell.d/kernel-headers-test.sh | 89 +++++++++++++++++++ 13 files changed, 138 insertions(+), 13 deletions(-) create mode 100755 bin/omarchy-pkg-add-kernel-headers create mode 100644 migrations/1789444024.sh create mode 100755 test/shell.d/kernel-headers-test.sh diff --git a/bin/omarchy-install-gaming-xbox-controllers b/bin/omarchy-install-gaming-xbox-controllers index f4134f76..8f7bdf28 100755 --- a/bin/omarchy-install-gaming-xbox-controllers +++ b/bin/omarchy-install-gaming-xbox-controllers @@ -10,7 +10,8 @@ set -e echo "Installing Xbox controller Bluetooth support..." # Install xpadneo to ensure controllers work out of the box -omarchy-pkg-add linux-headers xpadneo-dkms +omarchy-pkg-add-kernel-headers +omarchy-pkg-add xpadneo-dkms # Prevent xpad/xpadneo driver conflict echo blacklist xpad | sudo tee /etc/modprobe.d/blacklist-xpad.conf >/dev/null diff --git a/bin/omarchy-pkg-add-kernel-headers b/bin/omarchy-pkg-add-kernel-headers new file mode 100755 index 00000000..28590b36 --- /dev/null +++ b/bin/omarchy-pkg-add-kernel-headers @@ -0,0 +1,13 @@ +#!/bin/bash + +# omarchy:summary=Install headers for the supported system kernel +# omarchy:hidden=true +# omarchy:requires-sudo=true + +# Inspect the target's packages: uname reports the live ISO kernel in a chroot. +# A retained stock kernel must not take precedence over the Omarchy kernel. +if omarchy-pkg-present linux-t2; then + omarchy-pkg-add linux-t2-headers +else + omarchy-pkg-add linux-omarchy-headers +fi diff --git a/install/hardware/apple/fix-spi-keyboard.sh b/install/hardware/apple/fix-spi-keyboard.sh index 330081b7..fd5d8e45 100644 --- a/install/hardware/apple/fix-spi-keyboard.sh +++ b/install/hardware/apple/fix-spi-keyboard.sh @@ -3,6 +3,7 @@ product_name="$(cat /sys/class/dmi/id/product_name 2>/dev/null)" if [[ $product_name =~ MacBook[89],1|MacBook1[02],1|MacBookPro13,[123]|MacBookPro14,[123] ]]; then echo "Detected MacBook with SPI keyboard" + omarchy-pkg-add-kernel-headers omarchy-pkg-add macbook12-spi-driver-dkms sudo mkdir -p /etc/mkinitcpio.conf.d if [[ $product_name == "MacBook8,1" ]]; then diff --git a/install/hardware/fix-bcm43xx.sh b/install/hardware/fix-bcm43xx.sh index 94fd16e6..f829749e 100644 --- a/install/hardware/fix-bcm43xx.sh +++ b/install/hardware/fix-bcm43xx.sh @@ -6,5 +6,6 @@ pci_info=$(lspci -nn) if (echo "$pci_info" | grep -q "14e4:43a0" || echo "$pci_info" | grep -q "14e4:4331"); then echo "BCM4360 / BCM4331 detected" - omarchy-pkg-add broadcom-wl-dkms linux-omarchy-headers + omarchy-pkg-add-kernel-headers + omarchy-pkg-add broadcom-wl-dkms fi diff --git a/install/hardware/fix-elgato-camlink-4k.sh b/install/hardware/fix-elgato-camlink-4k.sh index 274076ce..d12cbfe4 100644 --- a/install/hardware/fix-elgato-camlink-4k.sh +++ b/install/hardware/fix-elgato-camlink-4k.sh @@ -4,8 +4,8 @@ # v4l2-relayd re-exposes it at 1280x720 under the same name. if omarchy-hw-elgato-camlink-4k; then - mapfile -t kernel_headers < <(pacman -Qqs '^linux(-zen|-lts|-hardened|-t2|-ptl|-omarchy(-bore|-ptl-novrr-mm)?)?$' | sed 's/$/-headers/') - omarchy-pkg-add "${kernel_headers[@]}" v4l2loopback-dkms v4l2loopback-utils v4l2-relayd + omarchy-pkg-add-kernel-headers + omarchy-pkg-add v4l2loopback-dkms v4l2loopback-utils v4l2-relayd sudo install -Dm644 "$OMARCHY_PATH/default/udev/elgato-camlink-4k.rules" /etc/udev/rules.d/71-elgato-camlink-4k.rules sudo install -Dm644 "$OMARCHY_PATH/default/v4l2-relayd/camlink.conf" /etc/v4l2-relayd.d/camlink.conf diff --git a/install/hardware/fix-tuxedo-backlight.sh b/install/hardware/fix-tuxedo-backlight.sh index a2d3b8c6..c40fadb8 100644 --- a/install/hardware/fix-tuxedo-backlight.sh +++ b/install/hardware/fix-tuxedo-backlight.sh @@ -1,7 +1,8 @@ # Install Tuxedo drivers for keyboard backlighting on Tuxedo laptops and # compatible devices like the Slimbook Executive (Clevo/Tuxedo chassis). if cat /sys/class/dmi/id/sys_vendor 2>/dev/null | grep -qi "TUXEDO\|Slimbook"; then - omarchy-pkg-add linux-omarchy-headers tuxedo-drivers-nocompatcheck-dkms + omarchy-pkg-add-kernel-headers + omarchy-pkg-add tuxedo-drivers-nocompatcheck-dkms # Blacklist the legacy clevo_xsm_wmi module which conflicts with the tuxedo-drivers # clevo_wmi module. When clevo_xsm_wmi loads first, it grabs the Clevo WMI GUIDs, diff --git a/install/hardware/fix-yt6801-ethernet-adapter.sh b/install/hardware/fix-yt6801-ethernet-adapter.sh index 7c0360e6..e20cb92e 100644 --- a/install/hardware/fix-yt6801-ethernet-adapter.sh +++ b/install/hardware/fix-yt6801-ethernet-adapter.sh @@ -1,4 +1,5 @@ # Install drivers for Motorcomm YT6801 ethernet adapter used by the Slimbook Executive if lspci | grep -i "YT6801\|Motorcomm.*Ethernet"; then - omarchy-pkg-add linux-omarchy-headers yt6801-dkms + omarchy-pkg-add-kernel-headers + omarchy-pkg-add yt6801-dkms fi diff --git a/install/hardware/intel/ipu7-camera.sh b/install/hardware/intel/ipu7-camera.sh index 97e5549b..45f1aced 100644 --- a/install/hardware/intel/ipu7-camera.sh +++ b/install/hardware/intel/ipu7-camera.sh @@ -1,5 +1,6 @@ # Install MIPI camera support for Intel IPU7 hardware if grep -q "OVTI08F4" /sys/bus/acpi/devices/*/hid 2>/dev/null; then + omarchy-pkg-add-kernel-headers omarchy-pkg-add intel-ipu7-camera fi diff --git a/install/hardware/nvidia.sh b/install/hardware/nvidia.sh index 48f62cd4..3b9adc18 100644 --- a/install/hardware/nvidia.sh +++ b/install/hardware/nvidia.sh @@ -1,8 +1,4 @@ if lspci | grep -qi 'nvidia'; then - # Check which kernel is installed and set appropriate headers package - KERNEL_PACKAGE=$(pacman -Qqs '^linux(-zen|-lts|-hardened|-t2|-ptl|-omarchy(-bore|-ptl-novrr-mm)?)?$' | head -1 || true) - [[ -n $KERNEL_PACKAGE ]] && omarchy-pkg-add "$KERNEL_PACKAGE-headers" - if omarchy-hw-nvidia-gsp; then PACKAGES=(nvidia-open-dkms nvidia-utils lib32-nvidia-utils libva-nvidia-driver) elif omarchy-hw-nvidia-without-gsp; then @@ -15,6 +11,7 @@ if lspci | grep -qi 'nvidia'; then exit 0 fi + omarchy-pkg-add-kernel-headers omarchy-pkg-add "${PACKAGES[@]}" # Per-session Hyprland NVIDIA env vars are handled by default/hypr/nvidia.lua. diff --git a/install/omarchy-other.packages b/install/omarchy-other.packages index 1f325b24..4a8b128f 100644 --- a/install/omarchy-other.packages +++ b/install/omarchy-other.packages @@ -20,9 +20,7 @@ libva-nvidia-driver limine limine-mkinitcpio-hook limine-snapper-sync -linux linux-firmware -linux-headers linux-omarchy linux-omarchy-headers macbook12-spi-driver-dkms diff --git a/migrations/1789444024.sh b/migrations/1789444024.sh new file mode 100644 index 00000000..05107342 --- /dev/null +++ b/migrations/1789444024.sh @@ -0,0 +1,8 @@ +echo "Install missing headers for the Omarchy or T2 kernel" + +# Fresh ISO installs mark earlier migrations complete, so the kernel migration +# cannot repair headers omitted by those installers. Package installation is +# idempotent when another user has already applied this repair. +if omarchy-pkg-present linux-omarchy || omarchy-pkg-present linux-t2; then + omarchy-pkg-add-kernel-headers +fi diff --git a/test/acceptance.d/system-test.sh b/test/acceptance.d/system-test.sh index 0d648e9c..af0d5204 100644 --- a/test/acceptance.d/system-test.sh +++ b/test/acceptance.d/system-test.sh @@ -24,6 +24,20 @@ verify_core_packages() { pass "all Omarchy core packages are installed (${#missing[@]} missing)" } +verify_kernel_headers() { + local kernel=linux-omarchy + local release + release=$(uname -r) + omarchy-pkg-present linux-t2 && kernel=linux-t2 + + [[ $(cat "/usr/lib/modules/$release/pkgbase") == "$kernel" ]] || + fail "the installed system boots the supported kernel" "$release is not $kernel" + omarchy-pkg-present "$kernel-headers" || fail "kernel headers are installed" "$kernel-headers is missing" + [[ $(cat "/usr/lib/modules/$release/build/include/config/kernel.release") == "$release" ]] || + fail "headers match the running kernel" "$release has missing or mismatched headers" + pass "the running $kernel kernel has matching headers ($release)" +} + verify_defaults() { [[ $(omarchy-default-browser) == "chromium" ]] || fail "Chromium is the default browser" pass "Chromium is the default browser" @@ -112,7 +126,7 @@ verify_user_setup() { pass "Omarchy user state and shell configuration exist" } -for check in verify_core_packages verify_defaults verify_services verify_runtime_tools verify_user_setup; do +for check in verify_core_packages verify_kernel_headers verify_defaults verify_services verify_runtime_tools verify_user_setup; do if ! ("$check"); then status=1 fi diff --git a/test/shell.d/kernel-headers-test.sh b/test/shell.d/kernel-headers-test.sh new file mode 100755 index 00000000..51db25d0 --- /dev/null +++ b/test/shell.d/kernel-headers-test.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT +mkdir -p "$tmp_dir/bin" "$tmp_dir/etc/modprobe.d" "$tmp_dir/sys/class/dmi/id" "$tmp_dir/sys/bus/acpi/devices/camera" +export INSTALLED_PACKAGES="$tmp_dir/installed" CALL_LOG="$tmp_dir/calls" +export PATH="$tmp_dir/bin:$ROOT/bin:$PATH" OMARCHY_PATH="$ROOT" + +cat > "$tmp_dir/bin/pacman" <<'SH' +#!/bin/bash +[[ $1 == "-Q" ]] || exit 1 +grep -Fxq -- "$2" "$INSTALLED_PACKAGES" +SH +cat > "$tmp_dir/bin/omarchy-pkg-add" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >> "$CALL_LOG" +[[ ${FAIL_HEADERS:-0} != 1 || $* != *-headers* ]] +SH +cat > "$tmp_dir/bin/lspci" <<'SH' +#!/bin/bash +printf '%s\n' 'NVIDIA [10de:2560]' 'Broadcom [14e4:43a0]' 'Motorcomm YT6801 Ethernet' +SH +cat > "$tmp_dir/bin/id" <<'SH' +#!/bin/bash +echo input +SH +for command in sudo lsmod omarchy-hw-nvidia-gsp omarchy-hw-elgato-camlink-4k; do + printf '#!/bin/bash\nexit 0\n' > "$tmp_dir/bin/$command" +done +chmod +x "$tmp_dir/bin/"* + +for installed in linux-omarchy linux-t2 linux; do + echo "$installed" > "$INSTALLED_PACKAGES" + : > "$CALL_LOG" + bash -euo pipefail "$ROOT/migrations/1789444024.sh" >/dev/null + if [[ $installed == "linux" ]]; then + [[ ! -s $CALL_LOG ]] || fail "header repair skips systems without a supported kernel" + else + [[ $(cat "$CALL_LOG") == "$installed-headers" ]] || fail "header repair covers existing $installed installs" + fi +done +pass "header repair covers existing Omarchy and T2 installs" +echo MacBook8,1 > "$tmp_dir/sys/class/dmi/id/product_name" +echo TUXEDO > "$tmp_dir/sys/class/dmi/id/sys_vendor" +echo OVTI08F4 > "$tmp_dir/sys/bus/acpi/devices/camera/hid" + +for installed in 'linux-omarchy' 'linux linux-omarchy' 'linux' 'linux-t2' 'linux linux-omarchy linux-t2'; do + expected=linux-omarchy-headers + [[ $installed == *linux-t2* ]] && expected=linux-t2-headers + read -ra packages <<< "$installed" + printf '%s\n' "${packages[@]}" > "$INSTALLED_PACKAGES" + : > "$CALL_LOG" + omarchy-pkg-add-kernel-headers + [[ $(cat "$CALL_LOG") == "$expected" ]] || fail "headers match supported kernel with $installed installed" + pass "headers match supported kernel with $installed installed" +done + +# Run every DKMS installer against hardware fixtures and stub package writes. +# Redirect absolute filesystem paths in sourced leaves into the fixture too. +for family in linux-omarchy linux-t2; do + printf '%s\n' linux "$family" > "$INSTALLED_PACKAGES" + for script in \ + bin/omarchy-install-gaming-xbox-controllers \ + install/hardware/nvidia.sh \ + install/hardware/apple/fix-spi-keyboard.sh \ + install/hardware/intel/ipu7-camera.sh \ + install/hardware/fix-bcm43xx.sh \ + install/hardware/fix-tuxedo-backlight.sh \ + install/hardware/fix-yt6801-ethernet-adapter.sh \ + install/hardware/fix-elgato-camlink-4k.sh; do + sed -e "s|/etc/|$tmp_dir/etc/|g" -e "s|/sys/|$tmp_dir/sys/|g" \ + -e "s|/lib/modules/|$tmp_dir/lib/modules/|g" "$ROOT/$script" > "$tmp_dir/installer.sh" + : > "$CALL_LOG" + bash -e "$tmp_dir/installer.sh" >/dev/null + [[ $(head -1 "$CALL_LOG") == "$family-headers" ]] || fail "$script installs $family headers first" "$(cat "$CALL_LOG")" + (( $(wc -l < "$CALL_LOG") == 2 )) || fail "$script installs its driver after the headers" + ! grep -wq linux-headers "$CALL_LOG" || fail "$script avoids stock headers" + + : > "$CALL_LOG" + if FAIL_HEADERS=1 bash -e "$tmp_dir/installer.sh" >/dev/null; then + fail "$script stops when headers cannot be installed" + fi + [[ $(cat "$CALL_LOG") == "$family-headers" ]] || fail "$script must not install DKMS drivers without headers" + pass "$script uses $family headers and stops on header installation failure" + done +done From 24417bf191e09fc4d672c74c34e184449e92aeda Mon Sep 17 00:00:00 2001 From: Ryan Hughes Date: Tue, 15 Sep 2026 00:46:30 -0400 Subject: [PATCH 42/48] Treat matching kernel headers as a base system guarantee --- agents/skills/install-scripts.md | 1 + bin/omarchy-install-gaming-xbox-controllers | 1 - bin/omarchy-pkg-add-kernel-headers | 13 --- install/hardware/apple/fix-spi-keyboard.sh | 1 - install/hardware/fix-bcm43xx.sh | 1 - install/hardware/fix-elgato-camlink-4k.sh | 1 - install/hardware/fix-tuxedo-backlight.sh | 1 - .../hardware/fix-yt6801-ethernet-adapter.sh | 1 - install/hardware/intel/ipu7-camera.sh | 1 - install/hardware/nvidia.sh | 1 - migrations/1789444024.sh | 8 +- test/shell.d/kernel-headers-migration-test.sh | 58 ++++++++++++ test/shell.d/kernel-headers-test.sh | 89 ------------------- 13 files changed, 64 insertions(+), 113 deletions(-) delete mode 100755 bin/omarchy-pkg-add-kernel-headers create mode 100755 test/shell.d/kernel-headers-migration-test.sh delete mode 100755 test/shell.d/kernel-headers-test.sh diff --git a/agents/skills/install-scripts.md b/agents/skills/install-scripts.md index 999a6e9c..aa993e5e 100644 --- a/agents/skills/install-scripts.md +++ b/agents/skills/install-scripts.md @@ -13,6 +13,7 @@ commands and reusable setup leaves: - use `$OMARCHY_INSTALL` and `$OMARCHY_PATH` instead of hard-coded Omarchy paths. - keep root-scoped hardware setup under `install/hardware/` and orchestrate it through `install/hardware/all.sh`. - keep every per-user setup leaf under `install/user/` (including `install/user/hardware/` and `install/user/first-run/`) so it is clear what must run for each user. +- The base install supplies matching kernel headers before hardware setup. DKMS installers should install their driver packages and assume the headers exist. - prefer helper commands for package and command checks where available. Raw `command -v`, `pacman`, and `pacman-key` are acceptable in package-helper diff --git a/bin/omarchy-install-gaming-xbox-controllers b/bin/omarchy-install-gaming-xbox-controllers index 8f7bdf28..f2cda4e7 100755 --- a/bin/omarchy-install-gaming-xbox-controllers +++ b/bin/omarchy-install-gaming-xbox-controllers @@ -10,7 +10,6 @@ set -e echo "Installing Xbox controller Bluetooth support..." # Install xpadneo to ensure controllers work out of the box -omarchy-pkg-add-kernel-headers omarchy-pkg-add xpadneo-dkms # Prevent xpad/xpadneo driver conflict diff --git a/bin/omarchy-pkg-add-kernel-headers b/bin/omarchy-pkg-add-kernel-headers deleted file mode 100755 index 28590b36..00000000 --- a/bin/omarchy-pkg-add-kernel-headers +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Install headers for the supported system kernel -# omarchy:hidden=true -# omarchy:requires-sudo=true - -# Inspect the target's packages: uname reports the live ISO kernel in a chroot. -# A retained stock kernel must not take precedence over the Omarchy kernel. -if omarchy-pkg-present linux-t2; then - omarchy-pkg-add linux-t2-headers -else - omarchy-pkg-add linux-omarchy-headers -fi diff --git a/install/hardware/apple/fix-spi-keyboard.sh b/install/hardware/apple/fix-spi-keyboard.sh index fd5d8e45..330081b7 100644 --- a/install/hardware/apple/fix-spi-keyboard.sh +++ b/install/hardware/apple/fix-spi-keyboard.sh @@ -3,7 +3,6 @@ product_name="$(cat /sys/class/dmi/id/product_name 2>/dev/null)" if [[ $product_name =~ MacBook[89],1|MacBook1[02],1|MacBookPro13,[123]|MacBookPro14,[123] ]]; then echo "Detected MacBook with SPI keyboard" - omarchy-pkg-add-kernel-headers omarchy-pkg-add macbook12-spi-driver-dkms sudo mkdir -p /etc/mkinitcpio.conf.d if [[ $product_name == "MacBook8,1" ]]; then diff --git a/install/hardware/fix-bcm43xx.sh b/install/hardware/fix-bcm43xx.sh index f829749e..0f9cf474 100644 --- a/install/hardware/fix-bcm43xx.sh +++ b/install/hardware/fix-bcm43xx.sh @@ -6,6 +6,5 @@ pci_info=$(lspci -nn) if (echo "$pci_info" | grep -q "14e4:43a0" || echo "$pci_info" | grep -q "14e4:4331"); then echo "BCM4360 / BCM4331 detected" - omarchy-pkg-add-kernel-headers omarchy-pkg-add broadcom-wl-dkms fi diff --git a/install/hardware/fix-elgato-camlink-4k.sh b/install/hardware/fix-elgato-camlink-4k.sh index d12cbfe4..9ae9e343 100644 --- a/install/hardware/fix-elgato-camlink-4k.sh +++ b/install/hardware/fix-elgato-camlink-4k.sh @@ -4,7 +4,6 @@ # v4l2-relayd re-exposes it at 1280x720 under the same name. if omarchy-hw-elgato-camlink-4k; then - omarchy-pkg-add-kernel-headers omarchy-pkg-add v4l2loopback-dkms v4l2loopback-utils v4l2-relayd sudo install -Dm644 "$OMARCHY_PATH/default/udev/elgato-camlink-4k.rules" /etc/udev/rules.d/71-elgato-camlink-4k.rules diff --git a/install/hardware/fix-tuxedo-backlight.sh b/install/hardware/fix-tuxedo-backlight.sh index c40fadb8..7fb408e0 100644 --- a/install/hardware/fix-tuxedo-backlight.sh +++ b/install/hardware/fix-tuxedo-backlight.sh @@ -1,7 +1,6 @@ # Install Tuxedo drivers for keyboard backlighting on Tuxedo laptops and # compatible devices like the Slimbook Executive (Clevo/Tuxedo chassis). if cat /sys/class/dmi/id/sys_vendor 2>/dev/null | grep -qi "TUXEDO\|Slimbook"; then - omarchy-pkg-add-kernel-headers omarchy-pkg-add tuxedo-drivers-nocompatcheck-dkms # Blacklist the legacy clevo_xsm_wmi module which conflicts with the tuxedo-drivers diff --git a/install/hardware/fix-yt6801-ethernet-adapter.sh b/install/hardware/fix-yt6801-ethernet-adapter.sh index e20cb92e..f88a9cec 100644 --- a/install/hardware/fix-yt6801-ethernet-adapter.sh +++ b/install/hardware/fix-yt6801-ethernet-adapter.sh @@ -1,5 +1,4 @@ # Install drivers for Motorcomm YT6801 ethernet adapter used by the Slimbook Executive if lspci | grep -i "YT6801\|Motorcomm.*Ethernet"; then - omarchy-pkg-add-kernel-headers omarchy-pkg-add yt6801-dkms fi diff --git a/install/hardware/intel/ipu7-camera.sh b/install/hardware/intel/ipu7-camera.sh index 45f1aced..97e5549b 100644 --- a/install/hardware/intel/ipu7-camera.sh +++ b/install/hardware/intel/ipu7-camera.sh @@ -1,6 +1,5 @@ # Install MIPI camera support for Intel IPU7 hardware if grep -q "OVTI08F4" /sys/bus/acpi/devices/*/hid 2>/dev/null; then - omarchy-pkg-add-kernel-headers omarchy-pkg-add intel-ipu7-camera fi diff --git a/install/hardware/nvidia.sh b/install/hardware/nvidia.sh index 3b9adc18..1c36787b 100644 --- a/install/hardware/nvidia.sh +++ b/install/hardware/nvidia.sh @@ -11,7 +11,6 @@ if lspci | grep -qi 'nvidia'; then exit 0 fi - omarchy-pkg-add-kernel-headers omarchy-pkg-add "${PACKAGES[@]}" # Per-session Hyprland NVIDIA env vars are handled by default/hypr/nvidia.lua. diff --git a/migrations/1789444024.sh b/migrations/1789444024.sh index 05107342..b5bc9149 100644 --- a/migrations/1789444024.sh +++ b/migrations/1789444024.sh @@ -3,6 +3,8 @@ echo "Install missing headers for the Omarchy or T2 kernel" # Fresh ISO installs mark earlier migrations complete, so the kernel migration # cannot repair headers omitted by those installers. Package installation is # idempotent when another user has already applied this repair. -if omarchy-pkg-present linux-omarchy || omarchy-pkg-present linux-t2; then - omarchy-pkg-add-kernel-headers -fi +for kernel in linux-omarchy linux-t2; do + if omarchy-pkg-present "$kernel"; then + omarchy-pkg-add "$kernel-headers" + fi +done diff --git a/test/shell.d/kernel-headers-migration-test.sh b/test/shell.d/kernel-headers-migration-test.sh new file mode 100755 index 00000000..458ea330 --- /dev/null +++ b/test/shell.d/kernel-headers-migration-test.sh @@ -0,0 +1,58 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +tmp_dir=$(mktemp -d) +trap 'rm -rf "$tmp_dir"' EXIT +mkdir -p "$tmp_dir/bin" +export INSTALLED_PACKAGES="$tmp_dir/installed" CALL_LOG="$tmp_dir/calls" +export PATH="$tmp_dir/bin:$ROOT/bin:$PATH" + +# Keep the real package helpers, but contain every pacman transaction here. +cat > "$tmp_dir/bin/pacman" <<'SH' +#!/bin/bash +case "$1" in + -Q) grep -Fxq -- "$2" "$INSTALLED_PACKAGES" ;; + -S) + [[ ${FAIL_INSTALL:-0} == 0 ]] || exit 1 + shift 3 # -S --noconfirm --needed + printf '%s\n' "$@" >> "$INSTALLED_PACKAGES" + printf '%s\n' "$@" >> "$CALL_LOG" + ;; + *) exit 1 ;; +esac +SH +cat > "$tmp_dir/bin/sudo" <<'SH' +#!/bin/bash +[[ $1 == "pacman" ]] || exit 1 +"$@" +SH +chmod +x "$tmp_dir/bin/"* + +migration="$ROOT/migrations/1789444024.sh" +for kernels in linux-omarchy linux-t2 'linux-omarchy linux-t2'; do + read -ra installed <<< "$kernels" + printf '%s\n' linux linux-headers "${installed[@]}" > "$INSTALLED_PACKAGES" + : > "$CALL_LOG" + bash -euo pipefail "$migration" >/dev/null + for kernel in "${installed[@]}"; do + grep -Fxq "$kernel-headers" "$INSTALLED_PACKAGES" || fail "$kernel gets its headers" + done + : > "$CALL_LOG" + bash -euo pipefail "$migration" >/dev/null + [[ ! -s $CALL_LOG ]] || fail "header repair is idempotent" + pass "missing headers are repaired once for $kernels" +done + +printf '%s\n' linux linux-aarch64 > "$INSTALLED_PACKAGES" +: > "$CALL_LOG" +bash -euo pipefail "$migration" >/dev/null +[[ ! -s $CALL_LOG ]] || fail "header repair skips unrelated kernels" +pass "header repair skips unrelated kernels" + +echo linux-omarchy > "$INSTALLED_PACKAGES" +if FAIL_INSTALL=1 bash -euo pipefail "$migration" >/dev/null; then + fail "a failed header installation must leave the migration pending" +fi +pass "header installation failure is propagated" diff --git a/test/shell.d/kernel-headers-test.sh b/test/shell.d/kernel-headers-test.sh deleted file mode 100755 index 51db25d0..00000000 --- a/test/shell.d/kernel-headers-test.sh +++ /dev/null @@ -1,89 +0,0 @@ -#!/bin/bash - -set -euo pipefail -source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" - -tmp_dir=$(mktemp -d) -trap 'rm -rf "$tmp_dir"' EXIT -mkdir -p "$tmp_dir/bin" "$tmp_dir/etc/modprobe.d" "$tmp_dir/sys/class/dmi/id" "$tmp_dir/sys/bus/acpi/devices/camera" -export INSTALLED_PACKAGES="$tmp_dir/installed" CALL_LOG="$tmp_dir/calls" -export PATH="$tmp_dir/bin:$ROOT/bin:$PATH" OMARCHY_PATH="$ROOT" - -cat > "$tmp_dir/bin/pacman" <<'SH' -#!/bin/bash -[[ $1 == "-Q" ]] || exit 1 -grep -Fxq -- "$2" "$INSTALLED_PACKAGES" -SH -cat > "$tmp_dir/bin/omarchy-pkg-add" <<'SH' -#!/bin/bash -printf '%s\n' "$*" >> "$CALL_LOG" -[[ ${FAIL_HEADERS:-0} != 1 || $* != *-headers* ]] -SH -cat > "$tmp_dir/bin/lspci" <<'SH' -#!/bin/bash -printf '%s\n' 'NVIDIA [10de:2560]' 'Broadcom [14e4:43a0]' 'Motorcomm YT6801 Ethernet' -SH -cat > "$tmp_dir/bin/id" <<'SH' -#!/bin/bash -echo input -SH -for command in sudo lsmod omarchy-hw-nvidia-gsp omarchy-hw-elgato-camlink-4k; do - printf '#!/bin/bash\nexit 0\n' > "$tmp_dir/bin/$command" -done -chmod +x "$tmp_dir/bin/"* - -for installed in linux-omarchy linux-t2 linux; do - echo "$installed" > "$INSTALLED_PACKAGES" - : > "$CALL_LOG" - bash -euo pipefail "$ROOT/migrations/1789444024.sh" >/dev/null - if [[ $installed == "linux" ]]; then - [[ ! -s $CALL_LOG ]] || fail "header repair skips systems without a supported kernel" - else - [[ $(cat "$CALL_LOG") == "$installed-headers" ]] || fail "header repair covers existing $installed installs" - fi -done -pass "header repair covers existing Omarchy and T2 installs" -echo MacBook8,1 > "$tmp_dir/sys/class/dmi/id/product_name" -echo TUXEDO > "$tmp_dir/sys/class/dmi/id/sys_vendor" -echo OVTI08F4 > "$tmp_dir/sys/bus/acpi/devices/camera/hid" - -for installed in 'linux-omarchy' 'linux linux-omarchy' 'linux' 'linux-t2' 'linux linux-omarchy linux-t2'; do - expected=linux-omarchy-headers - [[ $installed == *linux-t2* ]] && expected=linux-t2-headers - read -ra packages <<< "$installed" - printf '%s\n' "${packages[@]}" > "$INSTALLED_PACKAGES" - : > "$CALL_LOG" - omarchy-pkg-add-kernel-headers - [[ $(cat "$CALL_LOG") == "$expected" ]] || fail "headers match supported kernel with $installed installed" - pass "headers match supported kernel with $installed installed" -done - -# Run every DKMS installer against hardware fixtures and stub package writes. -# Redirect absolute filesystem paths in sourced leaves into the fixture too. -for family in linux-omarchy linux-t2; do - printf '%s\n' linux "$family" > "$INSTALLED_PACKAGES" - for script in \ - bin/omarchy-install-gaming-xbox-controllers \ - install/hardware/nvidia.sh \ - install/hardware/apple/fix-spi-keyboard.sh \ - install/hardware/intel/ipu7-camera.sh \ - install/hardware/fix-bcm43xx.sh \ - install/hardware/fix-tuxedo-backlight.sh \ - install/hardware/fix-yt6801-ethernet-adapter.sh \ - install/hardware/fix-elgato-camlink-4k.sh; do - sed -e "s|/etc/|$tmp_dir/etc/|g" -e "s|/sys/|$tmp_dir/sys/|g" \ - -e "s|/lib/modules/|$tmp_dir/lib/modules/|g" "$ROOT/$script" > "$tmp_dir/installer.sh" - : > "$CALL_LOG" - bash -e "$tmp_dir/installer.sh" >/dev/null - [[ $(head -1 "$CALL_LOG") == "$family-headers" ]] || fail "$script installs $family headers first" "$(cat "$CALL_LOG")" - (( $(wc -l < "$CALL_LOG") == 2 )) || fail "$script installs its driver after the headers" - ! grep -wq linux-headers "$CALL_LOG" || fail "$script avoids stock headers" - - : > "$CALL_LOG" - if FAIL_HEADERS=1 bash -e "$tmp_dir/installer.sh" >/dev/null; then - fail "$script stops when headers cannot be installed" - fi - [[ $(cat "$CALL_LOG") == "$family-headers" ]] || fail "$script must not install DKMS drivers without headers" - pass "$script uses $family headers and stops on header installation failure" - done -done From 45e32c8c2d000ffee5488e16fb7601100a2c0511 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 15 Sep 2026 08:10:58 -0400 Subject: [PATCH 43/48] Set up browser integration when choosing Claude --- bin/omarchy-agent | 2 +- bin/omarchy-default-agent | 5 +++ bin/omarchy-install-chromium-claude | 43 +++++++++++++++++++ manual/17-ai.md | 2 + test/shell.d/chromium-claude-test.sh | 50 +++++++++++++++++++++++ test/shell.d/default-agent-test.sh | 22 +++++++++- test/shell.d/unowned-system-paths-test.sh | 7 +++- 7 files changed, 126 insertions(+), 5 deletions(-) create mode 100755 bin/omarchy-install-chromium-claude create mode 100644 test/shell.d/chromium-claude-test.sh diff --git a/bin/omarchy-agent b/bin/omarchy-agent index e35bcf04..23d0e2f3 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -75,7 +75,7 @@ crush) fi ;; claude) - command=(claude --permission-mode auto) + command=(claude --permission-mode auto --chrome) [[ -n ${prompt:-} ]] && command+=(-- "$prompt") ;; grok) diff --git a/bin/omarchy-default-agent b/bin/omarchy-default-agent index af93d863..8c14a4f2 100755 --- a/bin/omarchy-default-agent +++ b/bin/omarchy-default-agent @@ -86,6 +86,11 @@ if ! agent_install; then exit 1 fi +if [[ $agent == "claude" ]] && ! omarchy-install-chromium-claude; then + echo "Could not install the Claude browser extension" >&2 + exit 1 +fi + mkdir -p "$(dirname "$agent_file")" printf '%s\n' "$agent" >"$agent_file" diff --git a/bin/omarchy-install-chromium-claude b/bin/omarchy-install-chromium-claude new file mode 100755 index 00000000..df338271 --- /dev/null +++ b/bin/omarchy-install-chromium-claude @@ -0,0 +1,43 @@ +#!/bin/bash + +# omarchy:summary=Install the Claude extension for Chromium-based browsers +# omarchy:requires-sudo=true + +set -euo pipefail + +if (( EUID == 0 )); then + export PATH=/usr/bin:/bin +fi + +EXTENSION_ID="fcoeoabgfenejglbffodgkkbkcdhcgfn" +EXTENSION_JSON='{ "external_update_url": "https://clients2.google.com/service/update2/crx" }' +PACKAGED_PATH=/usr/bin/omarchy-install-chromium-claude + +# Brave and Brave Origin share Chromium's external extension directory. +# Seed all supported browsers, including those installed after choosing Claude. +EXTENSION_DIRS=( + /usr/share/chromium/extensions + /usr/share/google-chrome/extensions + /usr/share/microsoft-edge/extensions +) + +installed=true +for dir in "${EXTENSION_DIRS[@]}"; do + if [[ ! -f $dir/$EXTENSION_ID.json ]] || [[ $(cat "$dir/$EXTENSION_ID.json") != "$EXTENSION_JSON" ]]; then + installed=false + fi +done + +if [[ $installed == "true" ]]; then + exit 0 +fi + +if (( EUID == 0 )); then + for dir in "${EXTENSION_DIRS[@]}"; do + printf '%s\n' "$EXTENSION_JSON" | install -D -m 0644 /dev/stdin "$dir/$EXTENSION_ID.json" + done +elif [[ -t 0 ]]; then + exec sudo "$PACKAGED_PATH" +else + exec pkexec "$PACKAGED_PATH" +fi diff --git a/manual/17-ai.md b/manual/17-ai.md index 8c3d48b0..7f76d481 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -26,6 +26,8 @@ To wrap an additional CLI the same way, run `omarchy-mise-install [com Pick your default agent with `omarchy default agent ` or under _Setup > Defaults > Agent_ in the Omarchy Menu (`Super + Space`). If the agent isn't installed yet, picking it installs it first. A fresh Omarchy will invite you to make this choice with a one-time notification. +Choosing Claude also sets up its browser extension for Chromium, Chrome, Brave, Brave Origin, and Edge, and enables browser integration when launched through `omarchy agent`. The extension setup applies to all users and may ask for your system password; cancelling leaves your previous default agent selected. Restart your browser, enable the extension if prompted, and sign in to Claude to finish connecting it. Run `/chrome` in Claude to check the connection. Firefox and Zen do not support this extension. If Claude was already your default before this setup was added, select it again to install the extension. + [Muse Code](https://dev.meta.ai) — Meta's `muse` — uses a preinstalled mise stub like the other agents. Picking it as the default installs Meta's official launcher through mise's HTTP backend. The launcher verifies and updates the native binary for your machine. Once you've chosen, `Super + Shift + Ctrl + A` launches the default agent in a dedicated terminal window (or brings up the picker if you haven't chosen yet). You can also launch it straight into a task with `omarchy agent prompt "Review this project"`. Agents launched this way run unattended in their respective don't-stop-to-ask modes, so be ready for them to actually do things! And since agents refuse to remember trust for your home directory, launches from `$HOME` start in `~/Work` instead. diff --git a/test/shell.d/chromium-claude-test.sh b/test/shell.d/chromium-claude-test.sh new file mode 100644 index 00000000..5d8c61ef --- /dev/null +++ b/test/shell.d/chromium-claude-test.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +# Exercise the privileged installer without writing to the host's /usr/share. +if ! command -v bwrap >/dev/null || ! bwrap --ro-bind / / --unshare-user --uid 0 --gid 0 true 2>/dev/null; then + pass "user namespaces unavailable; skipping isolated Claude extension installation" + exit 0 +fi + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT +mkdir -p "$test_tmp/share" "$test_tmp/bin" +installer="$ROOT/bin/omarchy-install-chromium-claude" +extension_id=fcoeoabgfenejglbffodgkkbkcdhcgfn +sandbox=(bwrap --ro-bind / / --bind "$test_tmp" "$test_tmp" --dev /dev --proc /proc --unshare-user) + +"${sandbox[@]}" --uid 0 --gid 0 --bind "$test_tmp/share" /usr/share bash "$installer" +for browser in chromium google-chrome microsoft-edge; do + file="$test_tmp/share/$browser/extensions/$extension_id.json" + jq -e '.external_update_url == "https://clients2.google.com/service/update2/crx"' "$file" >/dev/null || + fail "$browser registers the official Claude Web Store extension" + [[ $(stat -c '%a' "$file") == "644" ]] || fail "$browser extension registration is readable" +done +pass "Claude extension installer registers all supported browser families" + +cat >"$test_tmp/bin/pkexec" <<'SH' +#!/bin/bash +printf '%s\n' "$@" >"$AUTH_LOG" +exit 42 +SH +chmod +x "$test_tmp/bin/pkexec" +export AUTH_LOG="$test_tmp/auth-log" +export PATH="$test_tmp/bin:$PATH" + +# A read-only /usr/share and a failing auth stub prove that a repeated run +# neither rewrites the files nor requests authentication. +"${sandbox[@]}" --uid 1000 --gid 1000 --ro-bind "$test_tmp/share" /usr/share bash "$installer" "$mock_bin/omarchy-install-chromium-claude" <<'SH' +#!/bin/bash +echo claude-extension >>"$OMARCHY_TEST_STUB_LOG" +[[ ${OMARCHY_TEST_EXTENSION_FAIL:-false} != "true" ]] +SH + cat >"$mock_bin/omarchy-notification-send" <<'SH' #!/bin/bash printf '%s\0' "$@" >>"$OMARCHY_TEST_NOTIFICATION_HISTORY" @@ -406,9 +412,16 @@ declare -A expected_packages=( for selection in "${!expected_agents[@]}"; do expected=${expected_agents[$selection]} : >"$agent_open_log" + : >"$stub_log" OMARCHY_TEST_AGENT_INSTALLED=true omarchy-default-agent "$selection" [[ $(omarchy-default-agent) == $expected ]] || fail "default agent canonicalizes $selection" + if [[ $expected == "claude" ]]; then + grep -qx claude-extension "$stub_log" || fail "Claude selection installs the browser extension" + else + [[ ! -s $stub_log ]] || fail "other agents do not install the Claude extension" + fi + mapfile -d '' -t mise_args <"$mise_log" [[ ${mise_args[0]} == "use" && ${mise_args[1]} == "-g" ]] || fail "default agent installs $selection globally through mise" @@ -427,6 +440,11 @@ pass "default agent selects and opens every supported provider and alias" pass "default agent stores its selection in Omarchy user config" OMARCHY_TEST_AGENT_INSTALLED=true omarchy-default-agent pi +if OMARCHY_TEST_AGENT_INSTALLED=true OMARCHY_TEST_EXTENSION_FAIL=true omarchy-default-agent claude >"$test_tmp/extension-failure" 2>&1; then + fail "Claude selection fails when browser extension installation fails" +fi +[[ $(omarchy-default-agent) == "pi" ]] || fail "extension installation failure preserves the default agent" +pass "extension installation failure preserves the default agent" : >"$notification_history" : >"$agent_open_log" : >"$terminal_log" @@ -644,7 +662,7 @@ assert_launch pi pi "Review this project" assert_launch omp omp --auto-approve -- "Review this project" assert_launch opencode opencode --auto --prompt "Review this project" assert_launch ori ori code --interactive --prompt "Review this project" -assert_launch claude claude --permission-mode auto -- "Review this project" +assert_launch claude claude --permission-mode auto --chrome -- "Review this project" assert_launch codex codex --approve-for-me -- "Review this project" assert_launch muse muse --approval-mode never -- "Review this project" assert_launch crush crush run "Review this project" @@ -672,7 +690,7 @@ assert_bypass pi pi assert_bypass omp omp --auto-approve assert_bypass opencode opencode --auto assert_bypass ori ori code -assert_bypass claude claude --permission-mode auto +assert_bypass claude claude --permission-mode auto --chrome assert_bypass codex codex --approve-for-me assert_bypass muse muse --approval-mode never assert_bypass crush crush --yolo diff --git a/test/shell.d/unowned-system-paths-test.sh b/test/shell.d/unowned-system-paths-test.sh index e2450619..efe940b4 100755 --- a/test/shell.d/unowned-system-paths-test.sh +++ b/test/shell.d/unowned-system-paths-test.sh @@ -31,10 +31,13 @@ allowed = { # them so the hook does not exist where it does not apply. "/usr/lib/systemd/system-sleep", # Written through a variable, so the scan below cannot see them at the point - # they are written. Both drop configuration into another project's tree rather - # than Omarchy's, which is why neither is a candidate for omarchy-settings. + # they are written. These drop configuration into another project's tree + # rather than Omarchy's and are not candidates for omarchy-settings. "/usr/share/chromium/extensions", "/usr/lib/firefox/distribution", + # Claude's extension is registered only when the user selects Claude. + "/usr/share/google-chrome/extensions", + "/usr/share/microsoft-edge/extensions", # Static content that belongs in omarchy-settings. It cannot move there in the # same release that first ships omarchy-update-system-pkgs-when-conflicted: the # upgrade carrying the handler is the one that would hit the conflict, and the From 49418942c898c8ed9ea604c35a71f0d5275ae26c Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:50:20 +0300 Subject: [PATCH 44/48] Merge pull request #7807 from Adolanium/keyring-fail-loud Stop update-keyring from claiming success when key operations fail --- bin/omarchy-update-keyring | 9 ++ test/shell.d/update-keyring-test.sh | 137 ++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 test/shell.d/update-keyring-test.sh diff --git a/bin/omarchy-update-keyring b/bin/omarchy-update-keyring index d561e22a..3a1c1c17 100755 --- a/bin/omarchy-update-keyring +++ b/bin/omarchy-update-keyring @@ -3,6 +3,11 @@ # omarchy:summary=Ensure the Omarchy and Arch keyring packages are installed and populated # omarchy:requires-sudo=true +# omarchy-update runs this under set -e as a trusted pre-step, so a failed recv +# or a broken keyring has to stop this script here, not surface later as +# signature errors in the middle of the main transaction. +set -euo pipefail + if omarchy-pkg-missing omarchy-keyring || ! sudo pacman-key --list-keys 40DFB630FF42BCFFB047046CF0134EE680CAC571 &>/dev/null; then sudo pacman-key --recv-keys 40DFB630FF42BCFFB047046CF0134EE680CAC571 --keyserver keys.openpgp.org sudo pacman-key --lsign-key 40DFB630FF42BCFFB047046CF0134EE680CAC571 @@ -19,4 +24,8 @@ fi # Always reinstall, as the keyring can be updated without a package version bump. echo -e "\e[32m\nUpdate Arch signing keys\e[0m" sudo pacman -Sy --noconfirm archlinux-keyring >/dev/null 2> >(grep -vE '^warning: archlinux-keyring-[^ ]+ is up to date -- reinstalling$' >&2) + +# Say "correct" only once the key verifiably is: before the failure checks +# above, a failed recv or reinstall still ended here with exit 0. +sudo pacman-key --list-keys 40DFB630FF42BCFFB047046CF0134EE680CAC571 >/dev/null echo "Keys are correct" diff --git a/test/shell.d/update-keyring-test.sh b/test/shell.d/update-keyring-test.sh new file mode 100644 index 00000000..15d5bb52 --- /dev/null +++ b/test/shell.d/update-keyring-test.sh @@ -0,0 +1,137 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +log_file="$test_tmp/keyring.log" +mkdir -p "$stub_bin" + +# Behavior is driven by env vars so each case can pick its failure point: +# KEYRING_TEST_PKG_MISSING exit status of omarchy-pkg-missing (default 1: installed) +# KEYRING_TEST_LIST_FAIL_ON which --list-keys call fails, counted per run (default: none) +# KEYRING_TEST_RECV_STATUS exit status of --recv-keys (default 0) +# KEYRING_TEST_REINSTALL_STATUS exit status of the archlinux-keyring reinstall (default 0) +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash + +printf 'sudo' >>"$KEYRING_TEST_LOG" +for arg in "$@"; do + printf '\t%s' "$arg" >>"$KEYRING_TEST_LOG" +done +printf '\n' >>"$KEYRING_TEST_LOG" + +if [[ $1 == "pacman-key" && $2 == "--list-keys" ]]; then + calls_file="$KEYRING_TEST_DIR/list-calls" + calls=$(( $(cat "$calls_file" 2>/dev/null || echo 0) + 1 )) + echo "$calls" >"$calls_file" + if [[ ${KEYRING_TEST_LIST_FAIL_ON:-} == "$calls" ]]; then + exit 1 + fi + exit 0 +fi + +if [[ $1 == "pacman-key" && $2 == "--recv-keys" ]]; then + exit "${KEYRING_TEST_RECV_STATUS:-0}" +fi + +if [[ $1 == "pacman-key" && $2 == "--lsign-key" ]]; then + exit 0 +fi + +if [[ $1 == "pacman" && $* == *archlinux-keyring* ]]; then + exit "${KEYRING_TEST_REINSTALL_STATUS:-0}" +fi + +exit 0 +SH +chmod +x "$stub_bin/sudo" + +cat >"$stub_bin/omarchy-pkg-missing" <<'SH' +#!/bin/bash + +exit "${KEYRING_TEST_PKG_MISSING:-1}" +SH +chmod +x "$stub_bin/omarchy-pkg-missing" + +cat >"$stub_bin/omarchy-pkg-add" <<'SH' +#!/bin/bash + +printf 'pkg-add\t%s\n' "$1" >>"$KEYRING_TEST_LOG" +exit 0 +SH +chmod +x "$stub_bin/omarchy-pkg-add" + +run_keyring() { + KEYRING_TEST_LOG="$log_file" \ + KEYRING_TEST_DIR="$test_tmp" \ + PATH="$stub_bin:$PATH" \ + "$ROOT/bin/omarchy-update-keyring" "$@" +} + +# Everything healthy: the key and package are present, the reinstall works. +: >"$log_file" +rm -f "$test_tmp/list-calls" +run_keyring >"$test_tmp/ok.out" + +grep -F "Keys are correct" "$test_tmp/ok.out" >/dev/null || + fail "update-keyring reports success when the keyring is healthy" "$(cat "$test_tmp/ok.out")" +pass "update-keyring reports success when the keyring is healthy" + +grep -Eq $'^sudo\tpacman\t-Sy\t--noconfirm\tarchlinux-keyring$' "$log_file" || + fail "update-keyring still reinstalls archlinux-keyring" "$(cat "$log_file")" +pass "update-keyring still reinstalls archlinux-keyring" + +# Key and package missing: the full populate path runs and verifies at the end. +: >"$log_file" +rm -f "$test_tmp/list-calls" +KEYRING_TEST_PKG_MISSING=0 run_keyring >"$test_tmp/populate.out" + +grep -F "Keys are correct" "$test_tmp/populate.out" >/dev/null || + fail "update-keyring populates a missing keyring and reports success" "$(cat "$test_tmp/populate.out")" +for expected in 'recv-keys' 'lsign-key' $'pkg-add\tomarchy-keyring'; do + grep -Eq "$expected" "$log_file" || + fail "update-keyring populates a missing keyring and reports success" "$(cat "$log_file")" +done +pass "update-keyring populates a missing keyring and reports success" + +# recv-keys failing must stop the script, not end in "Keys are correct". +: >"$log_file" +rm -f "$test_tmp/list-calls" +if KEYRING_TEST_PKG_MISSING=0 KEYRING_TEST_RECV_STATUS=1 run_keyring >"$test_tmp/recv.out" 2>&1; then + fail "update-keyring fails when recv-keys fails" +fi +if grep -F "Keys are correct" "$test_tmp/recv.out" >/dev/null; then + fail "update-keyring fails when recv-keys fails" "$(cat "$test_tmp/recv.out")" +fi +if grep -q 'lsign-key' "$log_file"; then + fail "update-keyring stops at the failed recv instead of signing anyway" "$(cat "$log_file")" +fi +pass "update-keyring fails when recv-keys fails" + +# A failed archlinux-keyring reinstall must not end in success either. +: >"$log_file" +rm -f "$test_tmp/list-calls" +if KEYRING_TEST_REINSTALL_STATUS=1 run_keyring >"$test_tmp/reinstall.out" 2>&1; then + fail "update-keyring fails when the archlinux-keyring reinstall fails" +fi +if grep -F "Keys are correct" "$test_tmp/reinstall.out" >/dev/null; then + fail "update-keyring fails when the archlinux-keyring reinstall fails" "$(cat "$test_tmp/reinstall.out")" +fi +pass "update-keyring fails when the archlinux-keyring reinstall fails" + +# The closing check is what backs the success line: the first --list-keys +# passes (key present, populate skipped), the verifying one fails. +: >"$log_file" +rm -f "$test_tmp/list-calls" +if KEYRING_TEST_LIST_FAIL_ON=2 run_keyring >"$test_tmp/verify.out" 2>&1; then + fail "update-keyring fails when the final key check fails" +fi +if grep -F "Keys are correct" "$test_tmp/verify.out" >/dev/null; then + fail "update-keyring fails when the final key check fails" "$(cat "$test_tmp/verify.out")" +fi +pass "update-keyring fails when the final key check fails" From b44fb747803e22903ddab642b1aad517b223b677 Mon Sep 17 00:00:00 2001 From: Taksh Kothari Date: Tue, 15 Sep 2026 21:23:37 +0530 Subject: [PATCH 45/48] [Security] Keep screen-recording state out of world-writable /tmp (#8374) * Keep screen-recording state out of world-writable /tmp * Compare the /tmp name across the run instead of requiring it absent Co-Authored-By: Claude Opus 5 (1M context) * Fall back to the state directory when there is no runtime dir * Let the /tmp snapshot come back empty Co-Authored-By: Claude Opus 5 (1M context) * Resolve the region file the same way in the resizer Co-Authored-By: Claude Opus 5 (1M context) * Protect recording fallback state and document its path --------- Co-authored-by: Omabot Co-authored-by: Claude Opus 5 (1M context) --- bin/omarchy-capture-screenrecording | 25 +++- bin/omarchy-capture-webcam-resize | 5 +- default/agents/skills/omarchy/capture.md | 2 +- default/agents/skills/omarchy/contributing.md | 2 +- test/shell.d/screenrecording-test.sh | 111 ++++++++++++++++++ 5 files changed, 137 insertions(+), 8 deletions(-) diff --git a/bin/omarchy-capture-screenrecording b/bin/omarchy-capture-screenrecording index d57b26fe..0c4c5494 100755 --- a/bin/omarchy-capture-screenrecording +++ b/bin/omarchy-capture-screenrecording @@ -15,8 +15,9 @@ # start. # # Env: OMARCHY_SCREENRECORD_DEBUG=true appends gpu-screen-recorder's stderr (and -# the picker target it was launched with) to /tmp/omarchy-screenrecord.log so -# users can attach a log when reporting capture failures. +# the picker target it was launched with) to +# $XDG_RUNTIME_DIR/omarchy-screenrecord.log so users can attach a log when +# reporting capture failures. [[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs OUTPUT_DIR="${OMARCHY_SCREENRECORD_DIR:-${XDG_VIDEOS_DIR:-$HOME/Videos}}" @@ -34,9 +35,18 @@ WEBCAM_SIZE="medium" RESOLUTION="" FULLSCREEN="false" STOP_RECORDING="false" -RECORDING_FILE="/tmp/omarchy-screenrecord-filename" -REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region" -LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "/tmp/omarchy-screenrecord.log" || echo "/dev/null") +# Both of these live in the per-user runtime directory, which is 0700. What +# RECORDING_FILE holds is read back on stop and used as a path -- ffmpeg writes +# beside it, `mv` replaces it, and `rm -f` deletes its preview -- so a name in +# world-writable /tmp is a name any other local account can create first and +# then point wherever it likes. The debug log carries the recorder's stderr and +# the picked geometry, which is nobody else's business either. Fall back to +# the state directory when there is no session runtime dir -- :-/tmp would put +# both files back at the fixed names this comment describes. +RUNTIME_DIR="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}" +RECORDING_FILE="$RUNTIME_DIR/omarchy-screenrecord-filename" +REGION_FILE="$RUNTIME_DIR/omarchy-screenrecord-region" +LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "$RUNTIME_DIR/omarchy-screenrecord.log" || echo "/dev/null") for arg in "$@"; do case "$arg" in @@ -144,6 +154,11 @@ select_capture_target() { } start_screenrecording() { + mkdir -p "$RUNTIME_DIR" || return 1 + # XDG_STATE_HOME may be outside a private home; protect existing fallbacks too. + if [[ -z ${XDG_RUNTIME_DIR:-} ]]; then + chmod 700 "$RUNTIME_DIR" || return 1 + fi local capture_args=() local target diff --git a/bin/omarchy-capture-webcam-resize b/bin/omarchy-capture-webcam-resize index cde61e08..3c9d1215 100755 --- a/bin/omarchy-capture-webcam-resize +++ b/bin/omarchy-capture-webcam-resize @@ -8,7 +8,10 @@ set -euo pipefail readonly MARGIN=40 -readonly REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region" +# Has to resolve to whatever omarchy-capture-screenrecording writes, fallback +# included, or a recording without a session runtime dir anchors the camera to +# the whole monitor instead of the region it recorded. +readonly REGION_FILE="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}/omarchy-screenrecord-region" usage() { echo "Usage: omarchy-capture-webcam-resize " >&2 diff --git a/default/agents/skills/omarchy/capture.md b/default/agents/skills/omarchy/capture.md index 41a52b6c..621bc298 100644 --- a/default/agents/skills/omarchy/capture.md +++ b/default/agents/skills/omarchy/capture.md @@ -33,7 +33,7 @@ Recordings land in the configured Videos directory (override with `omarchy capture webcam resize `. If recording fails to start, rerun with `OMARCHY_SCREENRECORD_DEBUG=true` to -collect a log at `/tmp/omarchy-screenrecord.log` worth attaching to a bug +collect a log at `$XDG_RUNTIME_DIR/omarchy-screenrecord.log` (or `${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/omarchy-screenrecord.log` without a session runtime directory) worth attaching to a bug report. ## Text Capture (OCR) diff --git a/default/agents/skills/omarchy/contributing.md b/default/agents/skills/omarchy/contributing.md index b5fd0533..c76c12d6 100644 --- a/default/agents/skills/omarchy/contributing.md +++ b/default/agents/skills/omarchy/contributing.md @@ -38,7 +38,7 @@ drag-and-drop in the web form, so save the capture and hand the user the file path to attach (`gh` cannot upload media). For screen-recording failures specifically, rerun with -`OMARCHY_SCREENRECORD_DEBUG=true` and attach `/tmp/omarchy-screenrecord.log`. +`OMARCHY_SCREENRECORD_DEBUG=true` and attach `$XDG_RUNTIME_DIR/omarchy-screenrecord.log` (or `${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/omarchy-screenrecord.log` without a session runtime directory). File the issue with `gh` when available: diff --git a/test/shell.d/screenrecording-test.sh b/test/shell.d/screenrecording-test.sh index 7859ec14..438ccc57 100644 --- a/test/shell.d/screenrecording-test.sh +++ b/test/shell.d/screenrecording-test.sh @@ -299,3 +299,114 @@ grep -F 'move = { "(monitor_w-monitor_h*2/9-40)", "(monitor_h-monitor_h/4-40)" } grep -F 'move = { "(monitor_w-monitor_h*3/10-40)", "(monitor_h-monitor_h*27/80-40)" }' "$webcam_rules" >/dev/null || \ fail "large webcam starts at its final corner position" pass "webcam size rules place the initial window in its final corner" + +# The stop path reads the recording state file back and uses its contents as a +# path -- ffmpeg writes beside it, `mv` replaces it, `rm -f` deletes its +# preview -- so it has to live in the per-user runtime directory rather than +# under a name in world-writable /tmp that another account can create first. +recording_dir="$tmp_dir/recordings" +mkdir -p "$recording_dir" + +cat >"$stub_bin/pgrep" <<'SH' +#!/bin/bash +exit 1 +SH + +cat >"$stub_bin/omarchy-hyprland-monitor-focused" <<'SH' +#!/bin/bash +printf 'DP-1\n' +SH + +cat >"$stub_bin/gpu-screen-recorder" <<'SH' +#!/bin/bash +for i in "$@"; do + [[ -n ${take_next:-} ]] && { : >"$i"; break; } + [[ $i == "-o" ]] && take_next=1 +done +sleep 5 +SH + +cat >"$stub_bin/omarchy-shell" <<'SH' +#!/bin/bash +exit 0 +SH + +chmod +x "$stub_bin"/pgrep "$stub_bin"/omarchy-hyprland-monitor-focused \ + "$stub_bin"/gpu-screen-recorder "$stub_bin"/omarchy-shell + +# Compare that name across the run rather than demanding it be absent: the +# whole point of the finding is that anyone can own it already, and a leftover +# from a pre-fix recording would red-light the fixed script. +tmp_state="/tmp/omarchy-screenrecord-filename" +tmp_state_before=$(stat -c '%y %s' "$tmp_state" 2>/dev/null || true) + +OMARCHY_SCREENRECORD_DIR="$recording_dir" \ + "$ROOT/bin/omarchy-capture-screenrecording" --fullscreen >/dev/null 2>&1 + +pkill -f "$stub_bin/gpu-screen-recorder" 2>/dev/null || true + +[[ $(stat -c '%y %s' "$tmp_state" 2>/dev/null) == "$tmp_state_before" ]] || + fail "screen recording keeps no state under a fixed /tmp name" +pass "screen recording keeps no state under a fixed /tmp name" + +[[ -s $XDG_RUNTIME_DIR/omarchy-screenrecord-filename ]] || + fail "the recording state file lives in the per-user runtime directory" \ + "$(ls -a "$XDG_RUNTIME_DIR")" +pass "the recording state file lives in the per-user runtime directory" + +[[ $(<"$XDG_RUNTIME_DIR/omarchy-screenrecord-filename") == "$recording_dir"/* ]] || + fail "the recording state file names the recording that was started" \ + "$(<"$XDG_RUNTIME_DIR/omarchy-screenrecord-filename")" +pass "the recording state file names the recording that was started" + +# The :-/tmp fallback would reopen the hole this PR closes. A recording +# started without a session runtime dir has to land under the state directory. +state_home="$tmp_dir/state-home" +mkdir -p "$state_home/omarchy" "$tmp_dir/home-fallback" +chmod 755 "$state_home/omarchy" +tmp_state_before=$(stat -c '%y %s' "$tmp_state" 2>/dev/null || true) + +env -u XDG_RUNTIME_DIR \ + HOME="$tmp_dir/home-fallback" \ + XDG_STATE_HOME="$state_home" \ + OMARCHY_SCREENRECORD_DIR="$recording_dir" \ + "$ROOT/bin/omarchy-capture-screenrecording" --fullscreen >/dev/null 2>&1 + +pkill -f "$stub_bin/gpu-screen-recorder" 2>/dev/null || true + +[[ $(stat -c '%y %s' "$tmp_state" 2>/dev/null) == "$tmp_state_before" ]] || + fail "without a runtime dir, screen recording still keeps no state under a fixed /tmp name" +pass "without a runtime dir, screen recording still keeps no state under a fixed /tmp name" + +fallback_file="$state_home/omarchy/omarchy-screenrecord-filename" +[[ -s $fallback_file ]] || + fail "without a runtime dir the recording state file lives in the state directory" \ + "$(ls -la "$state_home/omarchy" 2>/dev/null || true)" +pass "without a runtime dir the recording state file lives in the state directory" + +[[ $(<"$fallback_file") == "$recording_dir"/* ]] || + fail "the fallback state file names the recording that was started" \ + "$(<"$fallback_file")" +pass "the fallback state file names the recording that was started" + +# The overlay resizer reads the region file the recorder writes, so the two +# have to resolve the same fallback as well as the same runtime dir. +: >"$OMARCHY_TEST_HYPRCTL_ARGS" +echo "800x600+100+100" >"$state_home/omarchy/omarchy-screenrecord-region" +env -u XDG_RUNTIME_DIR \ + HOME="$tmp_dir/home-fallback" \ + XDG_STATE_HOME="$state_home" \ + "$ROOT/bin/omarchy-capture-webcam-resize" reset + +printf '%s\n' \ + 'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 133, y = 150 })' \ + 'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 727, y = 510 })' >"$expected_hyprctl_args" + +if ! cmp -s "$OMARCHY_TEST_HYPRCTL_ARGS" "$expected_hyprctl_args"; then + fail "without a runtime dir the webcam anchors to the recorded region" "$(diff -u "$expected_hyprctl_args" "$OMARCHY_TEST_HYPRCTL_ARGS")" +fi +pass "without a runtime dir the webcam anchors to the recorded region" + +mode=$(stat -c '%a' "$state_home/omarchy" 2>/dev/null || stat -f '%Lp' "$state_home/omarchy") +[[ $mode == "700" ]] || fail "fallback directory is private even when it already existed" "mode: $mode" +pass "fallback directory is private even when it already existed" From a73bcbfc0a9570ba7072791639dc97eb567b6f41 Mon Sep 17 00:00:00 2001 From: Erik Melton Date: Tue, 15 Sep 2026 18:02:17 +0200 Subject: [PATCH 46/48] Remove unsafe project bin PATH injection (#11336) * Remove unsafe project bin PATH injection * Cover customized unsafe Mise paths * Revoke legacy Mise Work trust * Harden legacy Mise trust cleanup * Preserve ignored Mise Work configs * Scope Mise path cleanup to env * Accept paranoid Mise ignore marker Reported-by: infosec-us-team --- install/user/mise-work.sh | 7 - manual/19-shell-tools.md | 2 + migrations/1789095456.sh | 99 ++++++++++ test/shell.d/mise-work-path-test.sh | 270 ++++++++++++++++++++++++++++ 4 files changed, 371 insertions(+), 7 deletions(-) create mode 100644 migrations/1789095456.sh create mode 100644 test/shell.d/mise-work-path-test.sh diff --git a/install/user/mise-work.sh b/install/user/mise-work.sh index f77d5561..9ce5249f 100644 --- a/install/user/mise-work.sh +++ b/install/user/mise-work.sh @@ -2,13 +2,6 @@ mkdir -p "$HOME/Work" mkdir -p "$HOME/Work/tries" -cat >"$HOME/Work/.mise.toml" <<'EOF' -[env] -_.path = "{{ cwd }}/bin" -EOF - -mise trust ~/Work/.mise.toml - # Offline installs unpack the Node tarball bundled by the ISO: from # /opt/packages in the ISO chroot, or from the copy staged in provisioning state when # omarchy-provision-owner finalizes the user at first boot. diff --git a/manual/19-shell-tools.md b/manual/19-shell-tools.md index 69965fdd..8252d795 100644 --- a/manual/19-shell-tools.md +++ b/manual/19-shell-tools.md @@ -57,3 +57,5 @@ The full manual can be found via `man yt-dlp`. ## try [try](https://github.com/tobi/try) makes it easy to manage programming experiments with date-stamped directories. All experiments live in `~/Work/tries` and you can access them via `try`. + +Omarchy does not add a project's `bin/` directory to `PATH` automatically. Run trusted project-local tools with an explicit relative path, such as `bin/rails` or `./bin/dev`. diff --git a/migrations/1789095456.sh b/migrations/1789095456.sh new file mode 100644 index 00000000..381557fc --- /dev/null +++ b/migrations/1789095456.sh @@ -0,0 +1,99 @@ +echo "Remove automatic project bin directories from PATH" + +work_dir="$HOME/Work" +mise_config="$work_dir/.mise.toml" +# install/user/mise-work.sh as shipped in Omarchy 4.0.3. +stock_sha="bd04f191d63bbde86920f44f76f0989fad980afc84e268e8474c201ec7149245" +cwd_bin='\{\{[[:space:]]*cwd[[:space:]]*\}\}/bin' +unsafe_path="^[[:space:]]*_[.]path[[:space:]]*=[[:space:]]*(\"$cwd_bin\"|'$cwd_bin')[[:space:]]*(#.*)?$" +env_section='^[[:space:]]*\[[[:space:]]*env[[:space:]]*\][[:space:]]*(#.*)?$' +any_section='^[[:space:]]*\[\[?.*\]\]?[[:space:]]*(#.*)?$' + +remove_empty_work_dir=false +if [[ ! -e $work_dir ]]; then + mkdir -p "$work_dir" + remove_empty_work_dir=true +fi + +was_ignored=false +if [[ -d $work_dir ]]; then + mise_state_dir=${MISE_STATE_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/mise} + ignored_configs_dir="$mise_state_dir/ignored-configs" + work_target=$(readlink -m "$work_dir") + config_path_target="$work_target/.mise.toml" + config_target=$(readlink -m "$mise_config") + + if [[ -d $ignored_configs_dir ]]; then + for ignored_entry in "$ignored_configs_dir"/*; do + [[ -L $ignored_entry ]] || continue + ignored_target=$(readlink "$ignored_entry") + if [[ $ignored_target == $work_target || $ignored_target == $config_path_target || $ignored_target == $config_target ]]; then + was_ignored=true + break + fi + done + fi + + if [[ $was_ignored == "false" ]]; then + # Normal Mise trust is recorded against the config-root directory, while + # paranoid trust is recorded against the file and its contents. Stage an + # empty, inert config when the legacy file is gone so either trust mode can + # resolve and revoke the original grant. + remove_empty_mise_config=false + if [[ ! -e $mise_config && ! -L $mise_config ]]; then + if (set -o noclobber; : >"$mise_config") 2>/dev/null; then + remove_empty_mise_config=true + fi + fi + + untrust_target="$work_dir" + if [[ -f $mise_config ]]; then + untrust_target="$mise_config" + fi + + if mise trust --untrust "$untrust_target"; then + : + else + if [[ $remove_empty_mise_config == "true" ]]; then + rm -f -- "$mise_config" + fi + exit 1 + fi + + if [[ $remove_empty_mise_config == "true" ]]; then + rm -f -- "$mise_config" + fi + fi +fi + +if [[ -f $mise_config ]]; then + if [[ ! -L $mise_config && $(sha256sum "$mise_config" | cut -d ' ' -f 1) == $stock_sha ]]; then + rm -f -- "$mise_config" + else + unsafe_env_paths=$(sed -n -E "\\%$env_section%,\\%$any_section% { \\%$unsafe_path%p; }" "$mise_config") + if [[ -n $unsafe_env_paths ]]; then + backup=$(mktemp "$mise_config.bak.XXXXXX") + cp -p -- "$mise_config" "$backup" + sed --follow-symlinks -i -E "\\%$env_section%,\\%$any_section% { \\%$unsafe_path%d; }" "$mise_config" + + printf '\n%s\n' \ + "Automatic project bin directories were removed from your Mise PATH." \ + "Your other Mise settings were preserved." + printf '\nBackup saved to:\n %s\n' "$backup" + fi + fi +fi + +if [[ -f $mise_config ]]; then + if [[ $was_ignored == "true" ]]; then + printf '\n%s\n' "This custom config remains ignored by Mise." + else + printf '\n%s\n %s\n' \ + "Mise trust for this custom config was revoked. Review it before trusting it again:" \ + "mise trust $mise_config" + fi +fi + +if [[ $remove_empty_work_dir == "true" ]]; then + rmdir "$work_dir" 2>/dev/null || true +fi diff --git a/test/shell.d/mise-work-path-test.sh b/test/shell.d/mise-work-path-test.sh new file mode 100644 index 00000000..9c50b91b --- /dev/null +++ b/test/shell.d/mise-work-path-test.sh @@ -0,0 +1,270 @@ +#!/bin/bash + +set -euo pipefail +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +require_command mise + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT +migration="$ROOT/migrations/1789095456.sh" + +run_migration() { + local test_home="$1" + + env -i \ + HOME="$test_home" \ + XDG_CACHE_HOME="$test_home/.cache" \ + XDG_CONFIG_HOME="$test_home/.config" \ + XDG_DATA_HOME="$test_home/.local/share" \ + XDG_STATE_HOME="$test_home/.local/state" \ + MISE_PARANOID="${OMARCHY_TEST_MISE_PARANOID:-false}" \ + PATH=/usr/bin \ + bash -euo pipefail "$migration" +} + +run_mise() { + local test_home="$1" + shift + + env -i \ + HOME="$test_home" \ + XDG_CACHE_HOME="$test_home/.cache" \ + XDG_CONFIG_HOME="$test_home/.config" \ + XDG_DATA_HOME="$test_home/.local/share" \ + XDG_STATE_HOME="$test_home/.local/state" \ + MISE_PARANOID="${OMARCHY_TEST_MISE_PARANOID:-false}" \ + PATH=/usr/bin \ + mise "$@" +} + +mise_environment() { + local test_home="$1" + local project="$2" + + ( + cd "$project" + run_mise "$test_home" env -s bash + ) +} + +mise_path_active() { + local test_home="$1" + local project="$2" + local output + + if ! output=$(mise_environment "$test_home" "$project" 2>/dev/null); then + return 1 + fi + + grep -F "$project/bin" <<<"$output" >/dev/null +} + +assert_unsafe_variant_removed() { + local variant="$1" + local assignment="$2" + local variant_home="$test_dir/$variant-home" + local variant_config="$variant_home/Work/.mise.toml" + local variant_project="$variant_home/Work/tries/untrusted-repository" + + mkdir -p "$variant_project/bin" + printf '[env]\n%s\n' "$assignment" >"$variant_config" + run_mise "$variant_home" trust "$variant_config" >/dev/null + + mise_path_active "$variant_home" "$variant_project" || fail "$variant legacy config prepends the repository bin directory" + + run_migration "$variant_home" >/dev/null + if mise_path_active "$variant_home" "$variant_project"; then + fail "$variant repository bin directory remains in PATH after migration" + fi +} + +install_home="$test_dir/install-home" +install_log="$test_dir/install-mise.log" +mkdir -p "$install_home" "$test_dir/bin" +cat >"$test_dir/bin/mise" <<'SH' +#!/bin/bash +printf '%s\n' "$*" >>"$MISE_TEST_LOG" +SH +chmod +x "$test_dir/bin/mise" + +env \ + HOME="$install_home" \ + MISE_TEST_LOG="$install_log" \ + OMARCHY_SETUP_CONTEXT=runtime \ + PATH="$test_dir/bin:/usr/bin" \ + bash -euo pipefail -c 'source "$1"' bash "$ROOT/install/user/mise-work.sh" + +[[ -d $install_home/Work/tries ]] || fail "installer creates the work and tries directories" +[[ ! -e $install_home/Work/.mise.toml ]] || fail "installer does not create a trusted Work Mise config" +[[ $(<"$install_log") == "use -g node@latest" ]] || fail "installer only invokes Mise for the global Node setup" +pass "new installs do not add project bin directories to PATH" + +stock_home="$test_dir/stock-home" +stock_config="$stock_home/Work/.mise.toml" +stock_project="$stock_home/Work/tries/untrusted-repository" +mkdir -p "$stock_project/bin" +cat >"$stock_config" <<'TOML' +[env] +_.path = "{{ cwd }}/bin" +TOML +run_mise "$stock_home" trust "$stock_config" >/dev/null + +mise_path_active "$stock_home" "$stock_project" || fail "legacy config prepends the repository bin directory" + +run_migration "$stock_home" >/dev/null +[[ ! -e $stock_config ]] || fail "migration removes the stock Work Mise config" +cat >"$stock_config" <<'TOML' +[env] +_.path = "{{ cwd }}/bin" +TOML +if mise_path_active "$stock_home" "$stock_project"; then + fail "recreated Work config remains trusted after migration" +fi +run_migration "$stock_home" >/dev/null +[[ ! -e $stock_config ]] || fail "stock migration is idempotent" +pass "migration removes the repository bin directory and revokes the Work trust root" + +assert_unsafe_variant_removed inline-comment '_.path = "{{ cwd }}/bin" # Omarchy default' +assert_unsafe_variant_removed single-quoted "_.path = '{{ cwd }}/bin'" +pass "migration removes annotated and single-quoted project bin paths" + +custom_home="$test_dir/custom-home" +custom_config="$custom_home/Work/.mise.toml" +mkdir -p "$(dirname "$custom_config")" +cat >"$custom_config" <<'TOML' +[env] +KEEP = "yes" + _.path = "{{ cwd }}/bin" +# _.path = "{{ cwd }}/bin" + +[tools] +ruby = "latest" + +[other] +_.path = "{{ cwd }}/bin" +TOML +cp "$custom_config" "$test_dir/custom-original" +cat >"$test_dir/custom-expected" <<'TOML' +[env] +KEEP = "yes" +# _.path = "{{ cwd }}/bin" + +[tools] +ruby = "latest" + +[other] +_.path = "{{ cwd }}/bin" +TOML +chmod 600 "$custom_config" +run_mise "$custom_home" trust "$custom_config" >/dev/null + +custom_output=$(run_migration "$custom_home") +cmp -s "$test_dir/custom-expected" "$custom_config" || fail "migration preserves unrelated custom Mise settings" +[[ $(stat -c %a "$custom_config") == "600" ]] || fail "migration preserves custom config permissions" +grep -F "mise trust $custom_config" <<<"$custom_output" >/dev/null || fail "migration explains how to review and re-trust a custom config" +custom_backups=("$custom_config".bak.*) +[[ -f ${custom_backups[0]} ]] || fail "migration backs up a customized Mise config" +(( ${#custom_backups[@]} == 1 )) || fail "migration creates one custom config backup" +cmp -s "$test_dir/custom-original" "${custom_backups[0]}" || fail "custom config backup preserves the original" + +run_migration "$custom_home" >/dev/null +custom_backups=("$custom_config".bak.*) +(( ${#custom_backups[@]} == 1 )) || fail "custom migration does not create another backup on rerun" +cmp -s "$test_dir/custom-expected" "$custom_config" || fail "custom migration is idempotent" +pass "custom Mise settings, permissions, and original backup survive the repair" + +unrelated_home="$test_dir/unrelated-home" +unrelated_config="$unrelated_home/Work/.mise.toml" +unrelated_project="$unrelated_home/Work/tries/untrusted-repository" +mkdir -p "$unrelated_project/bin" +printf '[env]\nKEEP = "yes"\n' >"$unrelated_config" +cp "$unrelated_config" "$test_dir/unrelated-original" +run_mise "$unrelated_home" trust "$unrelated_config" >/dev/null +run_migration "$unrelated_home" >/dev/null +cmp -s "$test_dir/unrelated-original" "$unrelated_config" || fail "unrelated Mise config remains unchanged" +unrelated_backups=("$unrelated_config".bak.*) +[[ ! -e ${unrelated_backups[0]} ]] || fail "unchanged Mise config is not backed up" +printf '[env]\n_.path = "{{ cwd }}/bin"\n' >"$unrelated_config" +if mise_path_active "$unrelated_home" "$unrelated_project"; then + fail "safe Work config retains its old trust grant" +fi + +absent_home="$test_dir/absent-home" +absent_config="$absent_home/Work/.mise.toml" +absent_project="$absent_home/Work/tries/untrusted-repository" +mkdir -p "$(dirname "$absent_config")" +printf '[env]\n_.path = "{{ cwd }}/bin"\n' >"$absent_config" +run_mise "$absent_home" trust "$absent_config" >/dev/null +rm "$absent_config" +rmdir "$absent_home/Work" +run_migration "$absent_home" >/dev/null +[[ ! -e $absent_home/Work ]] || fail "migration does not retain a temporary Work directory" +mkdir -p "$absent_project/bin" +printf '[env]\n_.path = "{{ cwd }}/bin"\n' >"$absent_config" +if mise_path_active "$absent_home" "$absent_project"; then + fail "deleted Work directory retains its stale trust grant" +fi +pass "migration leaves unrelated configs alone and revokes dangling Work trust" + +paranoid_home="$test_dir/paranoid-home" +paranoid_work="$paranoid_home/Work" +paranoid_config="$paranoid_work/.mise.toml" +paranoid_project="$paranoid_work/tries/untrusted-repository" +mkdir -p "$paranoid_project/bin" +printf '[env]\n_.path = "{{ cwd }}/bin"\n' >"$paranoid_config" +OMARCHY_TEST_MISE_PARANOID=true run_mise "$paranoid_home" trust "$paranoid_config" >/dev/null +OMARCHY_TEST_MISE_PARANOID=true mise_path_active "$paranoid_home" "$paranoid_project" || fail "paranoid legacy config prepends the repository bin directory" +rm -r "$paranoid_work" + +OMARCHY_TEST_MISE_PARANOID=true run_migration "$paranoid_home" >/dev/null +[[ ! -e $paranoid_work ]] || fail "paranoid migration removes its temporary Work directory" +mkdir -p "$paranoid_project/bin" +printf '[env]\n_.path = "{{ cwd }}/bin"\n' >"$paranoid_config" +if OMARCHY_TEST_MISE_PARANOID=true mise_path_active "$paranoid_home" "$paranoid_project"; then + fail "paranoid migration retains content-bound trust for the deleted legacy config" +fi +pass "migration revokes stale content-bound trust in Mise paranoid mode" + +ignored_home="$test_dir/ignored-home" +ignored_config="$ignored_home/Work/.mise.toml" +ignored_project="$ignored_home/Work/tries/untrusted-repository" +mkdir -p "$ignored_project/bin" +cat >"$ignored_config" <<'TOML' +[env] +_.path = "{{ cwd }}/bin" +KEEP = "yes" +TOML +run_mise "$ignored_home" trust "$ignored_config" >/dev/null +run_mise "$ignored_home" trust --ignore "$ignored_config" >/dev/null + +ignored_output=$(run_migration "$ignored_home") +grep -F '{{ cwd }}/bin' "$ignored_config" >/dev/null && fail "ignored config retains the unsafe path" +grep -Fx 'KEEP = "yes"' "$ignored_config" >/dev/null || fail "ignored config keeps unrelated settings" +ignored_entries=("$ignored_home/.local/state/mise/ignored-configs/"*) +[[ -L ${ignored_entries[0]} ]] || fail "migration preserves the explicit Mise ignore marker" +(( ${#ignored_entries[@]} == 1 )) || fail "migration preserves exactly one Mise ignore marker" +ignored_target=$(readlink "${ignored_entries[0]}") +[[ $ignored_target == $ignored_home/Work || $ignored_target == $ignored_config ]] || fail "preserved Mise ignore marker still targets the Work config" +grep -F "remains ignored by Mise" <<<"$ignored_output" >/dev/null || fail "migration reports that the custom config remains ignored" +if mise_path_active "$ignored_home" "$ignored_project"; then + fail "ignored config becomes active after migration" +fi +pass "migration preserves an explicit decision to ignore the Work config" + +symlink_home="$test_dir/symlink-home" +symlink_config="$symlink_home/Work/.mise.toml" +symlink_target="$test_dir/dotfiles-mise.toml" +mkdir -p "$(dirname "$symlink_config")" +cat >"$symlink_target" <<'TOML' +[env] +_.path = "{{ cwd }}/bin" +KEEP = "yes" +TOML +ln -s "$symlink_target" "$symlink_config" + +run_migration "$symlink_home" >/dev/null +[[ -L $symlink_config ]] || fail "migration preserves a dotfile symlink" +grep -F '{{ cwd }}/bin' "$symlink_target" >/dev/null && fail "symlink target retains the unsafe path" +grep -Fx 'KEEP = "yes"' "$symlink_target" >/dev/null || fail "symlink target keeps unrelated settings" +pass "custom dotfile symlinks survive the repair" From 8fa9f4d03e02411f62772f2defc2b6139e86d222 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 15 Sep 2026 12:38:00 -0400 Subject: [PATCH 47/48] Leave Claude browser integration settings unchanged --- bin/omarchy-agent | 2 +- manual/17-ai.md | 2 +- test/shell.d/default-agent-test.sh | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bin/omarchy-agent b/bin/omarchy-agent index 23d0e2f3..e35bcf04 100755 --- a/bin/omarchy-agent +++ b/bin/omarchy-agent @@ -75,7 +75,7 @@ crush) fi ;; claude) - command=(claude --permission-mode auto --chrome) + command=(claude --permission-mode auto) [[ -n ${prompt:-} ]] && command+=(-- "$prompt") ;; grok) diff --git a/manual/17-ai.md b/manual/17-ai.md index 7f76d481..24ade4f4 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -26,7 +26,7 @@ To wrap an additional CLI the same way, run `omarchy-mise-install [com Pick your default agent with `omarchy default agent ` or under _Setup > Defaults > Agent_ in the Omarchy Menu (`Super + Space`). If the agent isn't installed yet, picking it installs it first. A fresh Omarchy will invite you to make this choice with a one-time notification. -Choosing Claude also sets up its browser extension for Chromium, Chrome, Brave, Brave Origin, and Edge, and enables browser integration when launched through `omarchy agent`. The extension setup applies to all users and may ask for your system password; cancelling leaves your previous default agent selected. Restart your browser, enable the extension if prompted, and sign in to Claude to finish connecting it. Run `/chrome` in Claude to check the connection. Firefox and Zen do not support this extension. If Claude was already your default before this setup was added, select it again to install the extension. +Choosing Claude also sets up its browser extension for Chromium, Chrome, Brave, Brave Origin, and Edge. The extension setup applies to all users and may ask for your system password; cancelling leaves your previous default agent selected. Restart your browser, enable the extension if prompted, and sign in to Claude to finish connecting it. Run `/chrome` in Claude to check the connection. Firefox and Zen do not support this extension. If Claude was already your default before this setup was added, select it again to install the extension. [Muse Code](https://dev.meta.ai) — Meta's `muse` — uses a preinstalled mise stub like the other agents. Picking it as the default installs Meta's official launcher through mise's HTTP backend. The launcher verifies and updates the native binary for your machine. diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index 1ce05582..7fd06093 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -662,7 +662,7 @@ assert_launch pi pi "Review this project" assert_launch omp omp --auto-approve -- "Review this project" assert_launch opencode opencode --auto --prompt "Review this project" assert_launch ori ori code --interactive --prompt "Review this project" -assert_launch claude claude --permission-mode auto --chrome -- "Review this project" +assert_launch claude claude --permission-mode auto -- "Review this project" assert_launch codex codex --approve-for-me -- "Review this project" assert_launch muse muse --approval-mode never -- "Review this project" assert_launch crush crush run "Review this project" @@ -690,7 +690,7 @@ assert_bypass pi pi assert_bypass omp omp --auto-approve assert_bypass opencode opencode --auto assert_bypass ori ori code -assert_bypass claude claude --permission-mode auto --chrome +assert_bypass claude claude --permission-mode auto assert_bypass codex codex --approve-for-me assert_bypass muse muse --approval-mode never assert_bypass crush crush --yolo From 677e69d4f13e90ee83251d75e7839f76532f97bc Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Tue, 15 Sep 2026 12:40:25 -0400 Subject: [PATCH 48/48] Make Claude browser extension installation best effort --- bin/omarchy-default-agent | 5 ++--- manual/17-ai.md | 2 +- test/shell.d/default-agent-test.sh | 18 ++++++++++++------ 3 files changed, 15 insertions(+), 10 deletions(-) diff --git a/bin/omarchy-default-agent b/bin/omarchy-default-agent index 8c14a4f2..eb2ef7fb 100755 --- a/bin/omarchy-default-agent +++ b/bin/omarchy-default-agent @@ -86,9 +86,8 @@ if ! agent_install; then exit 1 fi -if [[ $agent == "claude" ]] && ! omarchy-install-chromium-claude; then - echo "Could not install the Claude browser extension" >&2 - exit 1 +if [[ $agent == "claude" ]]; then + omarchy-install-chromium-claude 2>/dev/null || true fi mkdir -p "$(dirname "$agent_file")" diff --git a/manual/17-ai.md b/manual/17-ai.md index 24ade4f4..0d62622d 100644 --- a/manual/17-ai.md +++ b/manual/17-ai.md @@ -26,7 +26,7 @@ To wrap an additional CLI the same way, run `omarchy-mise-install [com Pick your default agent with `omarchy default agent ` or under _Setup > Defaults > Agent_ in the Omarchy Menu (`Super + Space`). If the agent isn't installed yet, picking it installs it first. A fresh Omarchy will invite you to make this choice with a one-time notification. -Choosing Claude also sets up its browser extension for Chromium, Chrome, Brave, Brave Origin, and Edge. The extension setup applies to all users and may ask for your system password; cancelling leaves your previous default agent selected. Restart your browser, enable the extension if prompted, and sign in to Claude to finish connecting it. Run `/chrome` in Claude to check the connection. Firefox and Zen do not support this extension. If Claude was already your default before this setup was added, select it again to install the extension. +Choosing Claude also attempts to set up its browser extension for Chromium, Chrome, Brave, Brave Origin, and Edge. The extension setup applies to all users and may ask for your system password; cancelling or a failed extension install still selects and launches Claude. Restart your browser, enable the extension if prompted, and sign in to Claude to finish connecting it. Run `/chrome` in Claude to check the connection. Firefox and Zen do not support this extension. If Claude was already your default before this setup was added, select it again to install the extension. [Muse Code](https://dev.meta.ai) — Meta's `muse` — uses a preinstalled mise stub like the other agents. Picking it as the default installs Meta's official launcher through mise's HTTP backend. The launcher verifies and updates the native binary for your machine. diff --git a/test/shell.d/default-agent-test.sh b/test/shell.d/default-agent-test.sh index 7fd06093..60e630d3 100644 --- a/test/shell.d/default-agent-test.sh +++ b/test/shell.d/default-agent-test.sh @@ -25,7 +25,10 @@ mkdir -p "$mock_bin" "$test_home" cat >"$mock_bin/omarchy-install-chromium-claude" <<'SH' #!/bin/bash echo claude-extension >>"$OMARCHY_TEST_STUB_LOG" -[[ ${OMARCHY_TEST_EXTENSION_FAIL:-false} != "true" ]] +if [[ ${OMARCHY_TEST_EXTENSION_FAIL:-false} == "true" ]]; then + echo "Extension installation failed" >&2 + exit 1 +fi SH cat >"$mock_bin/omarchy-notification-send" <<'SH' @@ -440,11 +443,14 @@ pass "default agent selects and opens every supported provider and alias" pass "default agent stores its selection in Omarchy user config" OMARCHY_TEST_AGENT_INSTALLED=true omarchy-default-agent pi -if OMARCHY_TEST_AGENT_INSTALLED=true OMARCHY_TEST_EXTENSION_FAIL=true omarchy-default-agent claude >"$test_tmp/extension-failure" 2>&1; then - fail "Claude selection fails when browser extension installation fails" -fi -[[ $(omarchy-default-agent) == "pi" ]] || fail "extension installation failure preserves the default agent" -pass "extension installation failure preserves the default agent" +: >"$agent_open_log" +OMARCHY_TEST_AGENT_INSTALLED=true OMARCHY_TEST_EXTENSION_FAIL=true omarchy-default-agent claude >"$test_tmp/extension-failure" 2>&1 +[[ $(omarchy-default-agent) == "claude" ]] || fail "extension installation failure still selects Claude" +mapfile -d '' -t agent_open_args <"$agent_open_log" +[[ ${agent_open_args[*]} == "omarchy-agent" ]] || fail "extension installation failure still launches Claude" +[[ ! -s $test_tmp/extension-failure ]] || fail "extension installation failure is silent" +pass "extension installation failure silently continues selecting and launching Claude" +OMARCHY_TEST_AGENT_INSTALLED=true omarchy-default-agent pi : >"$notification_history" : >"$agent_open_log" : >"$terminal_log"