Compare commits
35
Commits
No files matched your search
@@ -61,19 +61,18 @@ its screenshots are also hosted.
|
||||
- [Prompt](manual/40-prompt.md)
|
||||
- [Branding](manual/41-branding.md)
|
||||
- [Common tweaks](manual/42-common-tweaks.md)
|
||||
- [Extra themes](manual/43-extra-themes.md)
|
||||
- [Making your own theme](manual/44-making-your-own-theme.md)
|
||||
- [Making your own theme](manual/43-making-your-own-theme.md)
|
||||
|
||||
**The Rest**
|
||||
|
||||
- [Mac support](manual/45-mac-support.md)
|
||||
- [Troubleshooting](manual/46-troubleshooting.md)
|
||||
- [FAQ](manual/47-faq.md)
|
||||
- [System snapshots](manual/48-system-snapshots.md)
|
||||
- [Security](manual/49-security.md)
|
||||
- [Omarchy on...](manual/50-omarchy-on.md)
|
||||
- [Dual Boot Install](manual/51-dual-boot-install.md)
|
||||
- [Unattended Installs](manual/52-unattended-installs.md)
|
||||
- [Mac support](manual/44-mac-support.md)
|
||||
- [Troubleshooting](manual/45-troubleshooting.md)
|
||||
- [FAQ](manual/46-faq.md)
|
||||
- [System snapshots](manual/47-system-snapshots.md)
|
||||
- [Security](manual/48-security.md)
|
||||
- [Omarchy on...](manual/49-omarchy-on.md)
|
||||
- [Dual Boot Install](manual/50-dual-boot-install.md)
|
||||
- [Unattended Installs](manual/51-unattended-installs.md)
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Name=Docker
|
||||
Exec=xdg-terminal-exec --app-id=TUI.tile -e lazydocker
|
||||
Exec=xdg-terminal-exec --app-id=TUI.tile -e omarchy-launch-docker-tui
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Icon=docker
|
||||
|
||||
+7
-3
@@ -74,12 +74,16 @@ crush)
|
||||
command=(crush --yolo)
|
||||
fi
|
||||
;;
|
||||
claude | grok)
|
||||
command=("$agent" --permission-mode bypassPermissions)
|
||||
claude)
|
||||
command=(claude --permission-mode auto)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
;;
|
||||
grok)
|
||||
command=(grok --permission-mode bypassPermissions)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
;;
|
||||
codex)
|
||||
command=(codex --dangerously-bypass-approvals-and-sandbox)
|
||||
command=(codex --approve-for-me)
|
||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||
;;
|
||||
omp)
|
||||
|
||||
@@ -228,7 +228,7 @@ stop_screenrecording() {
|
||||
|
||||
omarchy-notification-send "Screen recording saved" "Open with Super + Alt + , (or click this)" \
|
||||
-t 10000 --image "${preview:-$filename}" \
|
||||
--exec "$(printf 'mpv %q' "$filename")"
|
||||
--exec mpv -- "$filename"
|
||||
|
||||
# The shell loads the thumbnail into memory when the toast appears and never
|
||||
# re-reads the file, so the preview only has to outlive that load -- not the
|
||||
|
||||
@@ -70,7 +70,7 @@ case "$PROCESSING" in
|
||||
# notification outage must not report the capture itself as failed.
|
||||
omarchy-notification-send "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" \
|
||||
--image "$FILEPATH" \
|
||||
--exec "$(printf '%q %q' "$SCREENSHOT_EDITOR" "$FILEPATH")" || true
|
||||
--exec "$SCREENSHOT_EDITOR" "$FILEPATH" || true
|
||||
;;
|
||||
copy)
|
||||
grim -g "$SELECTION" - | wl-copy --type image/png
|
||||
|
||||
@@ -25,6 +25,51 @@ valid_url() {
|
||||
[[ $1 =~ ^https?:// ]]
|
||||
}
|
||||
|
||||
# A printed path is only usable if it is a regular file inside DOWNLOAD_DIR.
|
||||
# Forged records (leading-dash mpv options, paths with control chars, or
|
||||
# anything that escaped the download directory) must not reach the click command.
|
||||
resolve_download_file() {
|
||||
local candidate=$1 file_real dir_real
|
||||
|
||||
[[ -n $candidate ]] || return 1
|
||||
[[ $candidate != *$'\n'* && $candidate != *$'\r'* && $candidate != *$'\t'* ]] || return 1
|
||||
[[ -f $candidate ]] || return 1
|
||||
|
||||
# Read to a NUL: command substitution strips trailing newlines, which would
|
||||
# resolve a name ending in one to a different file that may well exist.
|
||||
IFS= read -r -d '' file_real < <(realpath -ze -- "$candidate") || return 1
|
||||
IFS= read -r -d '' dir_real < <(realpath -ze -- "$DOWNLOAD_DIR") || return 1
|
||||
|
||||
[[ $file_real != *$'\n'* && $file_real != *$'\r'* && $file_real != *$'\t'* ]] || return 1
|
||||
# Trim the slash so a download directory of "/" still leaves a usable prefix.
|
||||
[[ $file_real == "${dir_real%/}"/* ]] || return 1
|
||||
|
||||
printf '%s' "$file_real"
|
||||
}
|
||||
|
||||
# yt-dlp prints the title JSON-encoded, so a newline or tab in page metadata is an
|
||||
# escape sequence rather than a record boundary. This is toast text, never a command.
|
||||
decode_title() {
|
||||
local decoded
|
||||
|
||||
decoded=$(jq -r 'if type == "string" then . else empty end' <<<"$1" 2>/dev/null) || return 1
|
||||
decoded=${decoded%%[[:cntrl:]]*} # keep what a person would read, drop the forgery
|
||||
[[ -n $decoded && $decoded != -* ]] || return 1
|
||||
|
||||
printf '%s' "$decoded"
|
||||
}
|
||||
|
||||
title_from_file() {
|
||||
local name=${1##*/}
|
||||
name=${name%.*}
|
||||
name=${name//[$'\n\r\t']/}
|
||||
if [[ -z $name || $name == -* ]]; then
|
||||
printf '%s' "Video"
|
||||
else
|
||||
printf '%s' "$name"
|
||||
fi
|
||||
}
|
||||
|
||||
# Drive the Quickshell OSD — a single overlay that updates in place (like the
|
||||
# volume/brightness bar), so download progress never stacks like notifications.
|
||||
osd_progress() {
|
||||
@@ -41,16 +86,19 @@ download_url() {
|
||||
mkdir -p "$DOWNLOAD_DIR"
|
||||
|
||||
# Don't show anything until yt-dlp confirms there's actually a video to grab.
|
||||
if ! yt-dlp --no-playlist --simulate --quiet --no-warnings "$url" >/dev/null 2>&1; then
|
||||
if ! yt-dlp --no-playlist --simulate --quiet --no-warnings --no-exec --no-exec-before-download -- "$url" >/dev/null 2>&1; then
|
||||
omarchy-notification-send -u critical -g "No video found for download" "$url"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
osd_progress 0
|
||||
|
||||
# Stream the download: OMARCHY_PROG carries the percent (drives the OSD),
|
||||
# OMARCHY_FILE (printed only after a successful move) carries title + path.
|
||||
local line rest pct intpct last="" er nowms lastms=0 title="" filepath=""
|
||||
# Stream the download: OMARCHY_PROG carries the percent (drives the OSD), and
|
||||
# OMARCHY_FILE and OMARCHY_TITLE (printed only after a successful move) carry the
|
||||
# path and the title. The title is JSON-encoded so metadata cannot forge a record,
|
||||
# and the file is named after it: yt-dlp strips control characters from a filename
|
||||
# with or without --restrict-filenames, so a record is still only ever one line.
|
||||
local line pct intpct last="" er nowms lastms=0 title="" filepath="" resolved
|
||||
while IFS= read -r line; do
|
||||
case $line in
|
||||
OMARCHY_PROG*)
|
||||
@@ -67,22 +115,26 @@ download_url() {
|
||||
osd_progress "$intpct"
|
||||
;;
|
||||
OMARCHY_FILE*)
|
||||
rest=${line#OMARCHY_FILE$'\t'}
|
||||
title=${rest%%$'\t'*}
|
||||
filepath=${rest#*$'\t'}
|
||||
resolved=$(resolve_download_file "${line#OMARCHY_FILE$'\t'}") || continue
|
||||
filepath=$resolved
|
||||
;;
|
||||
OMARCHY_TITLE*)
|
||||
title=$(decode_title "${line#OMARCHY_TITLE$'\t'}") || title=""
|
||||
;;
|
||||
esac
|
||||
done < <(PYTHONUNBUFFERED=1 yt-dlp --no-playlist --restrict-filenames --no-simulate \
|
||||
--quiet --no-warnings --progress --newline \
|
||||
done < <(PYTHONUNBUFFERED=1 yt-dlp --no-playlist --no-simulate \
|
||||
--quiet --no-warnings --no-exec --no-exec-before-download --progress --newline \
|
||||
--progress-template $'download:OMARCHY_PROG\t%(progress._percent_str)s' \
|
||||
--paths "$DOWNLOAD_DIR" -o '%(title)s [%(id)s].%(ext)s' \
|
||||
--print $'after_move:OMARCHY_FILE\t%(title)s\t%(filepath)s' \
|
||||
"$url" 2>&1)
|
||||
--paths "$DOWNLOAD_DIR" -o '%(title)s.%(ext)s' \
|
||||
--print $'after_move:OMARCHY_FILE\t%(filepath)s' \
|
||||
--print $'after_move:OMARCHY_TITLE\t%(title)j' \
|
||||
-- "$url" 2>&1)
|
||||
|
||||
osd_close
|
||||
|
||||
# after_move only prints on a successful download+move, so a captured path == success.
|
||||
if [[ -n $filepath ]]; then
|
||||
[[ -n $title ]] || title=$(title_from_file "$filepath")
|
||||
((${#title} > 50)) && title="${title:0:50}…" # keep the toast compact
|
||||
|
||||
# Square, center-cropped thumbnail so the notification preview isn't stretched.
|
||||
@@ -94,9 +146,11 @@ download_url() {
|
||||
|
||||
# Best-effort: the download already succeeded, and under `set -e` a failed
|
||||
# toast would exit before the thumbnail cleanup below is ever scheduled.
|
||||
# `--` keeps mpv from parsing a leading-dash filename as an option; the path
|
||||
# is one discrete argument, so it never reaches a shell.
|
||||
omarchy-notification-send -g "Download complete" "$title" \
|
||||
-t 10000 --image "${preview:-$filepath}" \
|
||||
--exec "$(printf 'mpv %q' "$filepath")" || true
|
||||
--exec mpv -- "$filepath" || true
|
||||
|
||||
# The shell loads the thumbnail into memory when the toast appears and never
|
||||
# re-reads the file, so the preview only has to outlive that load, not the
|
||||
|
||||
@@ -24,9 +24,7 @@ readonly ignore_pattern=${OMARCHY_CRASH_IGNORE:-}
|
||||
declare -A last_notified
|
||||
|
||||
announce() {
|
||||
local comm=$1 pid=$2 exe=$3 signal=$4 exec_command
|
||||
|
||||
exec_command=$(printf 'omarchy-agent-crash %q %q %q %q' "$pid" "$comm" "$exe" "$signal")
|
||||
local comm=$1 pid=$2 exe=$3 signal=$4
|
||||
|
||||
# The shell owns org.freedesktop.Notifications, so a shell crash takes the
|
||||
# notification server down with it and a toast sent into that gap is lost.
|
||||
@@ -35,14 +33,16 @@ announce() {
|
||||
omarchy-notification-wait || return 1
|
||||
|
||||
# --exec rather than a libnotify action: the shell runs clicks from its own
|
||||
# omarchy-exec hint and never emits ActionInvoked. Keeps the default
|
||||
# "omarchy-action" app name too, the only one shouldBypassDnd() lets through.
|
||||
# hint and never emits ActionInvoked. Keeps the default "omarchy-action" app
|
||||
# name, the only one shouldBypassDnd() lets through. Crash details ride as
|
||||
# discrete argv words, so a hostile process name can't be reparsed as a
|
||||
# command. --exec consumes the rest of the line, so it comes last.
|
||||
omarchy-notification-send \
|
||||
--urgency critical \
|
||||
--glyph "$CRASH_GLYPH" \
|
||||
--exec "$exec_command" \
|
||||
"Process crashed: $comm" \
|
||||
"Click to diagnose with AI"
|
||||
"Click to diagnose with AI" \
|
||||
--exec omarchy-agent-crash "$pid" "$comm" "$exe" "$signal"
|
||||
}
|
||||
|
||||
# -n 0 so a restart does not re-announce crashes already dealt with.
|
||||
|
||||
+32
-7
@@ -6,6 +6,18 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Whenever this runs as root — invoked directly through the passwordless
|
||||
# sudoers rule, or re-execed by require_root below — sudo's secure_path decides
|
||||
# where a bare helper resolves, and a dev link (etc/sudoers.d/omarchy-dev-path)
|
||||
# prepends a user-writable checkout bin/ to it. Every helper this script calls
|
||||
# by bare name (dirname, install, tee, rm, nmcli, systemctl, awk) is a system
|
||||
# tool, never an omarchy-* command, so pin PATH to trusted system directories
|
||||
# and keep root from resolving one out of that checkout. The unprivileged
|
||||
# wrapper phase keeps the caller's PATH so it can still find sudo/pkexec.
|
||||
if (( EUID == 0 )); then
|
||||
export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/bin:/sbin
|
||||
fi
|
||||
|
||||
NM_DNS_CONF=/etc/NetworkManager/conf.d/20-omarchy-dns.conf
|
||||
|
||||
provider_from_arg() {
|
||||
@@ -28,19 +40,32 @@ provider_from_arg() {
|
||||
esac
|
||||
}
|
||||
|
||||
self_path() {
|
||||
readlink -f "$OMARCHY_PATH/bin/${BASH_SOURCE[0]##*/}"
|
||||
# The path etc/sudoers.d/omarchy-dns names. The privileged half always runs from
|
||||
# there rather than from whichever copy was invoked, so the rule matches even
|
||||
# where $OMARCHY_PATH points at a checkout.
|
||||
PACKAGED_PATH=/usr/bin/omarchy-dns
|
||||
|
||||
# True when sudo would run this exact command without stopping for a password.
|
||||
# `sudo -l` on its own reports whether a command is permitted, which the blanket
|
||||
# %wheel rule answers yes to for everything; the long listing prints the matched
|
||||
# entry's tags, so !authenticate is the grant in etc/sudoers.d/omarchy-dns and
|
||||
# nothing else. Listing runs nothing and, under -n, prompts for nothing, so a
|
||||
# machine whose omarchy-settings predates that file falls through to polkit
|
||||
# instead of dying on a password prompt it has no terminal to show.
|
||||
sudo_grants_passwordless() {
|
||||
sudo -n -l -l "$PACKAGED_PATH" "$@" 2>/dev/null | grep -q '!authenticate'
|
||||
}
|
||||
|
||||
require_root() {
|
||||
if (( EUID == 0 )); then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -t 0 ]]; then
|
||||
exec sudo "$(self_path)" "$@"
|
||||
elif [[ -t 0 ]] || sudo_grants_passwordless "$@"; then
|
||||
# A terminal can carry sudo's own password prompt. Without one, sudo is
|
||||
# right only where the grant reaches; polkit can at least put a prompt on
|
||||
# screen, and offer to authenticate as someone else.
|
||||
exec sudo "$PACKAGED_PATH" "$@"
|
||||
else
|
||||
exec pkexec "$(self_path)" "$@"
|
||||
exec pkexec "$PACKAGED_PATH" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Check that a git URL names a repository, not a transport helper
|
||||
# omarchy:args=<git-url>
|
||||
# omarchy:hidden=true
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# git picks a remote helper -- an executable it runs at clone time -- out of a URL
|
||||
# in exactly two shapes, and no others: `<helper>::<address>`, and
|
||||
# `<scheme>://<address>` for any scheme git does not handle itself. A single
|
||||
# colon is always scp-style ssh, and a bare path is always a path; neither can
|
||||
# reach a helper. So constraining those two shapes covers the whole surface.
|
||||
#
|
||||
# The `::` shape is refused outright, because no helper reachable that way is one
|
||||
# a theme or plugin URL has business naming, and `ext::` runs a shell command.
|
||||
# The `://` shape cannot be refused the same way, since it is also how every
|
||||
# legitimate URL arrives -- so it is allowlisted instead. The list is the
|
||||
# transports git still connects itself, `git+ssh` and `ssh+git` included: those
|
||||
# two are spelled like a helper but are read as plain ssh. `ext` and `fd` are
|
||||
# left out deliberately -- git ships a helper for each, and `ext` runs whatever
|
||||
# command the URL carries.
|
||||
TRANSPORTS=(ssh git git+ssh ssh+git http https ftp ftps file)
|
||||
|
||||
fail() {
|
||||
echo "omarchy-git-url-check: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
url="${1-}"
|
||||
|
||||
if [[ -z $url ]]; then
|
||||
fail "a git URL is required"
|
||||
fi
|
||||
|
||||
if [[ $url == -* || $url =~ ^[A-Za-z0-9][A-Za-z0-9+.-]*:: ]]; then
|
||||
fail "'$url' names a git option or transport helper, not a repository."
|
||||
fi
|
||||
|
||||
if [[ $url =~ ^([A-Za-z0-9][A-Za-z0-9+.-]*):// ]]; then
|
||||
scheme="${BASH_REMATCH[1]}"
|
||||
|
||||
for transport in "${TRANSPORTS[@]}"; do
|
||||
if [[ $scheme == "$transport" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
fail "'$url' names the '$scheme' transport, which Omarchy does not clone from."
|
||||
fi
|
||||
@@ -11,6 +11,14 @@ MONITOR_LUA="$HOME/.config/hypr/monitors.lua"
|
||||
|
||||
INTERNAL=$(omarchy-hyprland-monitor-laptop)
|
||||
|
||||
# INTERNAL is written into generated Lua and hyprctl eval/dispatch below, so a
|
||||
# name that is not a plain connector string could execute on the next reload.
|
||||
# Names come from hyprctl; a user-created headless output can carry anything.
|
||||
if [[ -n $INTERNAL && ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||
echo "Refusing unsafe internal monitor name" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
valid_scale() {
|
||||
[[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]]
|
||||
}
|
||||
@@ -118,9 +126,17 @@ remember_internal_scale() {
|
||||
store_internal_scale "$scale"
|
||||
}
|
||||
|
||||
# $1 is the configured scale when the caller has already read it, so one sync
|
||||
# does not parse the config twice.
|
||||
read_monitor_scale() {
|
||||
local scale
|
||||
scale=$(configured_monitor_scale)
|
||||
|
||||
if (( $# )); then
|
||||
scale="$1"
|
||||
else
|
||||
scale=$(configured_monitor_scale)
|
||||
fi
|
||||
|
||||
if valid_scale "$scale"; then
|
||||
echo "$scale"
|
||||
return
|
||||
@@ -159,11 +175,25 @@ enable_internal_output() {
|
||||
|
||||
sync_internal_scale() {
|
||||
[[ -n $INTERNAL ]] || return 0
|
||||
local configured_scale
|
||||
local desired_scale
|
||||
local active_scale
|
||||
|
||||
desired_scale=$(read_monitor_scale)
|
||||
configured_scale=$(configured_monitor_scale)
|
||||
active_scale=$(current_internal_scale)
|
||||
|
||||
# A config without a usable number -- the default "auto", or an expression
|
||||
# only Hyprland's Lua can evaluate -- delegates the scale to the compositor,
|
||||
# so whatever it resolved for the enabled panel IS the configured scale.
|
||||
# There is no number to correct it toward: substituting one makes the scale
|
||||
# flap between that number and the compositor's own value on every idle-wake.
|
||||
# Only a panel that is off altogether still gets a hand below, from the
|
||||
# remembered scale.
|
||||
if ! valid_scale "$configured_scale" && valid_scale "$active_scale"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
desired_scale=$(read_monitor_scale "$configured_scale")
|
||||
scales_match "$active_scale" "$desired_scale" && return 0
|
||||
|
||||
enable_internal_output "$desired_scale"
|
||||
|
||||
@@ -28,6 +28,13 @@ off() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The name is written into generated Lua below, so only a plain connector
|
||||
# name may pass; anything else could execute on the next reload.
|
||||
if [[ ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||
omarchy-notification-send -g "Refusing unsafe monitor name"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! omarchy-hyprland-monitor-external-active; then
|
||||
omarchy-notification-send -g "Can't disable the only active display"
|
||||
exit 1
|
||||
|
||||
@@ -22,6 +22,15 @@ on() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Both names are written into generated Lua below, so only plain connector
|
||||
# names may pass; a user-created headless output can carry any name.
|
||||
for output in "$INTERNAL" "$EXTERNAL"; do
|
||||
if [[ ! $output =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||
omarchy-notification-send -g "Refusing unsafe monitor name"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
omarchy-hyprland-toggle $DISABLE_TOGGLE off
|
||||
|
||||
if omarchy-hyprland-toggle-disabled $TOGGLE; then
|
||||
|
||||
@@ -80,6 +80,14 @@ set_scale() {
|
||||
local width="$(echo "$monitor_info" | jq -r '.width')"
|
||||
local height="$(echo "$monitor_info" | jq -r '.height')"
|
||||
local refresh_rate="$(echo "$monitor_info" | jq -r '.refreshRate')"
|
||||
|
||||
# active_monitor is written into the Lua string eval'd below, so only a plain
|
||||
# connector name may pass; a hostile output name could execute otherwise.
|
||||
if [[ ! $active_monitor =~ ^[A-Za-z0-9._-]+$ ]]; then
|
||||
echo "Refusing unsafe monitor name" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local new_scale="$(clean_scale "$requested_scale" "$width" "$height")"
|
||||
# GTK only honors integer GDK_SCALE values, so persist the nearest whole
|
||||
# factor even when the monitor scale itself is fractional.
|
||||
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Open the Docker TUI (lazydocker) with access to the Docker daemon
|
||||
# omarchy:hidden=true
|
||||
|
||||
# By default the install user is NOT in the docker group: membership is
|
||||
# root-equivalent (a container can bind-mount / and rewrite the host as root),
|
||||
# so a single process running as the user could otherwise escalate to root with
|
||||
# no prompt. lazydocker needs the root-owned Docker socket, so when the group is
|
||||
# absent, gate that access behind a polkit prompt. If the user has opted into
|
||||
# sudoless Docker (omarchy-setup-security-sudoless-docker), the socket is already
|
||||
# reachable, so run lazydocker directly — omarchy-sudo-docker answers that for
|
||||
# this session, so the prompt stays until the reboot that grants the group.
|
||||
# pkexec sanitizes the environment, so carry TERM through for the TUI to render
|
||||
# and run lazydocker from root's PATH.
|
||||
if omarchy-sudo-docker; then
|
||||
exec pkexec /usr/bin/env TERM="${TERM:-xterm-256color}" lazydocker
|
||||
else
|
||||
exec lazydocker
|
||||
fi
|
||||
@@ -38,7 +38,7 @@ id=$(cut -f2 <<<"$selection")
|
||||
|
||||
if [[ $1 == "clone" ]]; then
|
||||
omarchy-launch-floating-terminal-with-presentation \
|
||||
"omarchy-plugin-clone --edit $(printf '%q' "$id")"
|
||||
"omarchy-plugin-clone $(printf '%q' "$id") --edit"
|
||||
elif [[ $1 == "remove" ]]; then
|
||||
omarchy-launch-floating-terminal-with-presentation "omarchy-plugin-remove $(printf '%q' "$id")"
|
||||
else
|
||||
|
||||
@@ -41,7 +41,7 @@ fi
|
||||
# The shell keeps the click command with the toast, so this oneshot can hand the
|
||||
# invitation over and exit instead of staying activated until it is answered.
|
||||
omarchy-notification-send -u critical -g "Pending Omarchy Migrations" "$message" \
|
||||
--exec "omarchy-launch-floating-terminal-with-presentation omarchy-migrate" && exit 0
|
||||
--exec omarchy-launch-floating-terminal-with-presentation omarchy-migrate && exit 0
|
||||
|
||||
# Reached when the notification could not be handed off, so fall back to telling
|
||||
# the user in the terminal.
|
||||
|
||||
@@ -22,7 +22,7 @@ rm -f "$HOME/.local/bin/$command"
|
||||
cat >"$HOME/.local/bin/$command" <<EOF
|
||||
#!/bin/bash
|
||||
export MISE_MINIMUM_RELEASE_AGE=0
|
||||
mise use -g "$package" || exit 1
|
||||
mise use -g --quiet "$package" || exit 1
|
||||
exec mise x "$package" -- "$bin" "\$@"
|
||||
EOF
|
||||
|
||||
|
||||
+151
-63
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Send an Omarchy desktop notification
|
||||
# omarchy:args=[--exec <command>] [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [--image <path-or-uri>] <headline> [description] [notify-send options]
|
||||
# omarchy:args=[--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [-i <icon>] [-t <ms>] [-r <id>] [-p] [--image <path-or-uri>] <headline> [description] [--exec <program> [args...]]
|
||||
# omarchy:examples=omarchy notification send "Reminder" "5 minutes are up" -g
|
||||
|
||||
set -euo pipefail
|
||||
@@ -11,61 +11,75 @@ description=""
|
||||
glyph=
|
||||
urgency="low"
|
||||
app_name="omarchy-action"
|
||||
app_icon=""
|
||||
image=
|
||||
exec_command=
|
||||
args=()
|
||||
expire_timeout=-1
|
||||
replaces_id=0
|
||||
print_id=0
|
||||
exec_args=()
|
||||
exec_present=0
|
||||
parsed_option_args=0
|
||||
|
||||
usage() {
|
||||
echo "Usage: omarchy-notification-send [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [-i <icon>] [-t <ms>] [-r <id>] [-p] [--image <path-or-uri>] <headline> [description] [--exec <program> [args...]]" >&2
|
||||
}
|
||||
|
||||
# Recognize a known option, in both `--flag value` and `--flag=value` forms.
|
||||
# Returns 1 for anything unrecognized so the caller can decide (headline, or a
|
||||
# hard error in option position).
|
||||
parse_omarchy_option() {
|
||||
case $1 in
|
||||
-g | --glyph)
|
||||
if (($# < 2)); then
|
||||
echo "Missing value for $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
glyph=$2
|
||||
parsed_option_args=2
|
||||
local opt val nargs
|
||||
if [[ $1 == --?*=* ]]; then
|
||||
opt=${1%%=*}
|
||||
val=${1#*=}
|
||||
nargs=1
|
||||
else
|
||||
opt=$1
|
||||
val=${2-}
|
||||
nargs=2
|
||||
fi
|
||||
|
||||
# -p/--print-id is a flag; it takes no value.
|
||||
if [[ $opt == -p || $opt == --print-id ]]; then
|
||||
print_id=1
|
||||
parsed_option_args=1
|
||||
return 0
|
||||
fi
|
||||
|
||||
case $opt in
|
||||
-g | --glyph | -u | --urgency | --app-name | -i | --icon | --image | -r | --replace-id | -t | --expire-time) ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
|
||||
if ((nargs == 2)) && (($# < 2)); then
|
||||
echo "Missing value for $opt" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
case $opt in
|
||||
-g | --glyph) glyph=$val ;;
|
||||
-u | --urgency) urgency=$val ;;
|
||||
--app-name) app_name=$val ;;
|
||||
-i | --icon) app_icon=$val ;;
|
||||
--image) image=$val ;;
|
||||
-r | --replace-id)
|
||||
[[ $val =~ ^[0-9]+$ ]] || {
|
||||
echo "Invalid $opt value (numeric id expected): $val" >&2
|
||||
exit 1
|
||||
}
|
||||
replaces_id=$val
|
||||
;;
|
||||
-u | --urgency)
|
||||
if (($# < 2)); then
|
||||
echo "Missing value for $1" >&2
|
||||
-t | --expire-time)
|
||||
[[ $val =~ ^-?[0-9]+$ ]] || {
|
||||
echo "Invalid $opt value (milliseconds expected): $val" >&2
|
||||
exit 1
|
||||
fi
|
||||
urgency="$2"
|
||||
parsed_option_args=2
|
||||
return 0
|
||||
;;
|
||||
--app-name)
|
||||
if (($# < 2)); then
|
||||
echo "Missing value for $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
app_name=$2
|
||||
parsed_option_args=2
|
||||
return 0
|
||||
;;
|
||||
--image)
|
||||
if (($# < 2)); then
|
||||
echo "Missing value for $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
image=$2
|
||||
parsed_option_args=2
|
||||
return 0
|
||||
;;
|
||||
--exec)
|
||||
if (($# < 2)); then
|
||||
echo "Missing value for $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
exec_command=$2
|
||||
parsed_option_args=2
|
||||
return 0
|
||||
}
|
||||
expire_timeout=$val
|
||||
;;
|
||||
esac
|
||||
|
||||
return 1
|
||||
parsed_option_args=$nargs
|
||||
return 0
|
||||
}
|
||||
|
||||
while (($# > 0)); do
|
||||
@@ -77,47 +91,121 @@ while (($# > 0)); do
|
||||
done
|
||||
|
||||
if (($# < 1)); then
|
||||
echo "Usage: omarchy-notification-send [--exec <command>] [--app-name <app-name>] [-g <glyph>] [-u <low|normal|critical>] [--image <path-or-uri>] <headline> [description] [notify-send options]"
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
headline=$1
|
||||
shift
|
||||
|
||||
if (($# > 0)) && [[ $1 != -* ]]; then
|
||||
# The description is the next positional, taken as text even when it begins with
|
||||
# a dash — a body like "-50% off" or a negative number is content, not options.
|
||||
# Only a recognized option flag or --exec in that slot is not the description.
|
||||
known_flag() {
|
||||
case $1 in
|
||||
-g | --glyph | -u | --urgency | --app-name | -i | --icon | -t | --expire-time | --image | -r | --replace-id | -p | --print-id | --exec) return 0 ;;
|
||||
--glyph=* | --urgency=* | --app-name=* | --icon=* | --expire-time=* | --image=* | --replace-id=*) return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
}
|
||||
|
||||
if (($# > 0)) && ! known_flag "$1"; then
|
||||
description=$1
|
||||
shift
|
||||
fi
|
||||
|
||||
while (($# > 0)); do
|
||||
if parse_omarchy_option "$@"; then
|
||||
if [[ $1 == "--exec" ]]; then
|
||||
# --exec consumes the rest of the line as the click command's argv. The
|
||||
# caller's shell already tokenized those words into discrete arguments, and
|
||||
# the shell runs them as-is (never re-parsed), so untrusted data in an
|
||||
# argument is only ever one argument and can never become a command.
|
||||
# Detected only here, after the headline/description positionals are
|
||||
# captured, so an untrusted headline that is literally "--exec" is taken as
|
||||
# text and can't be mistaken for the delimiter. --exec therefore comes last.
|
||||
shift
|
||||
exec_args=("$@")
|
||||
exec_present=1
|
||||
break
|
||||
elif parse_omarchy_option "$@"; then
|
||||
shift "$parsed_option_args"
|
||||
else
|
||||
args+=("$1")
|
||||
shift
|
||||
echo "Unknown option: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Tag as a user-action toast so it pops through DND.
|
||||
args+=("-a" "$app_name" "-u" "$urgency")
|
||||
case $urgency in
|
||||
low) urgency_byte=0 ;;
|
||||
normal) urgency_byte=1 ;;
|
||||
critical) urgency_byte=2 ;;
|
||||
*)
|
||||
echo "Unknown urgency: $urgency (use low, normal, or critical)" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# a{sv} hints, as busctl triples (key, variant type, value). urgency is a byte;
|
||||
# the rest are strings. The click command rides here as omarchy-exec-argv, built
|
||||
# only from --exec below.
|
||||
hints=(urgency y "$urgency_byte")
|
||||
|
||||
if [[ -n $glyph ]]; then
|
||||
args+=("--hint=string:omarchy-glyph:$glyph")
|
||||
hints+=(omarchy-glyph s "$glyph")
|
||||
fi
|
||||
|
||||
if [[ -n $image ]]; then
|
||||
args+=("--hint=string:image-path:$image")
|
||||
hints+=(image-path s "$image")
|
||||
fi
|
||||
|
||||
# The shell runs the click command itself, from a copy it keeps alongside the
|
||||
# on-screen popup. A libnotify action would instead keep this process blocked
|
||||
# until the click, and die unanswered whenever the shell restarts underneath it.
|
||||
if [[ -n $exec_command ]]; then
|
||||
args+=("--hint=string:omarchy-exec:$exec_command")
|
||||
if ((exec_present)); then
|
||||
if ((${#exec_args[@]} == 0)) || [[ -z ${exec_args[0]} ]]; then
|
||||
echo "--exec needs a command: --exec <program> [args...]" >&2
|
||||
exit 1
|
||||
fi
|
||||
# A single word with a space is almost always a whole command passed as one
|
||||
# quoted string — which would run a program literally named that. Splitting it
|
||||
# ourselves is exactly the injection we avoid, so reject it and point at the
|
||||
# unquoted form instead.
|
||||
if ((${#exec_args[@]} == 1)) && [[ ${exec_args[0]} == *[[:space:]]* ]]; then
|
||||
echo "--exec takes the command as separate words, not one quoted string." >&2
|
||||
echo "Write: --exec ${exec_args[0]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
# NUL-delimit into jq so every byte survives as data: jq's own --args would eat
|
||||
# a bare "--", and a newline in an arg must not split the vector.
|
||||
exec_argv_json=$(printf '%s\0' "${exec_args[@]}" | jq -Rsc 'split("\u0000")[:-1]')
|
||||
hints+=(omarchy-exec-argv s "$exec_argv_json")
|
||||
fi
|
||||
|
||||
if [[ -n $description ]]; then
|
||||
notify-send "${args[@]}" "$headline" "$description"
|
||||
hint_count=$((${#hints[@]} / 3))
|
||||
|
||||
# Call org.freedesktop.Notifications.Notify directly — never notify-send. Its
|
||||
# argv parsing is the surface that reinterprets a relayed headline like
|
||||
# `--hint=…` or `-rf` as options or hints; busctl takes each value as one typed
|
||||
# D-Bus parameter instead, and the leading `--` keeps a dash-leading value
|
||||
# (headline, description, a negative timeout) positional rather than a busctl
|
||||
# option. So the summary and body are strings that can never become a hint, and
|
||||
# omarchy-exec-argv is set only from --exec.
|
||||
#
|
||||
# Signature susssasa{sv}i: app_name, replaces_id, app_icon, summary, body,
|
||||
# actions (empty), hints, expire_timeout. replaces_id (from -r) updates a toast
|
||||
# in place; -p prints the returned id so a caller can reuse it.
|
||||
notify_cmd=(
|
||||
busctl --user -- call
|
||||
org.freedesktop.Notifications /org/freedesktop/Notifications
|
||||
org.freedesktop.Notifications Notify susssasa{sv}i
|
||||
"$app_name" "$replaces_id" "$app_icon" "$headline" "$description"
|
||||
0
|
||||
"$hint_count" "${hints[@]}"
|
||||
"$expire_timeout"
|
||||
)
|
||||
|
||||
if ((print_id)); then
|
||||
# busctl prints the UINT32 return as "u <id>"; emit just the id.
|
||||
out=$("${notify_cmd[@]}")
|
||||
printf '%s\n' "${out##* }"
|
||||
else
|
||||
notify-send "${args[@]}" "$headline"
|
||||
"${notify_cmd[@]}" >/dev/null
|
||||
fi
|
||||
@@ -93,6 +93,13 @@ if [[ -z $url ]]; then
|
||||
[[ -n $url ]] || fail "a git URL is required"
|
||||
fi
|
||||
|
||||
# Refuse a URL that names a git option or a transport helper before cloning, so
|
||||
# an untrusted URL cannot run a command before the plugin is validated or
|
||||
# enabled. The check is shared with omarchy-theme-install and explains itself; a
|
||||
# missing checker leaves this non-zero, which refuses the URL rather than
|
||||
# cloning it.
|
||||
omarchy-git-url-check "$url" || exit 1
|
||||
|
||||
if (( ! ASSUME_YES )); then
|
||||
cat >&2 <<WARN
|
||||
|
||||
|
||||
@@ -34,6 +34,15 @@ if [[ ! -f $logo_path ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# omarchy-plymouth-set-by-theme passes a theme's unlock.png straight from
|
||||
# ~/.config/omarchy/themes, where an installed theme can make it a symlink to
|
||||
# anything. The copies below land in world-readable /usr/share, so following one
|
||||
# would republish whatever it points at.
|
||||
if [[ -L $logo_path ]]; then
|
||||
echo "Logo file is a symlink, which is not accepted: $logo_path" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bg_r=$(awk -v n=$((16#${bg_hex:0:2})) 'BEGIN{printf "%.3f", n/255}')
|
||||
bg_g=$(awk -v n=$((16#${bg_hex:2:2})) 'BEGIN{printf "%.3f", n/255}')
|
||||
bg_b=$(awk -v n=$((16#${bg_hex:4:2})) 'BEGIN{printf "%.3f", n/255}')
|
||||
@@ -72,7 +81,7 @@ sed \
|
||||
-e "s/#ffffff/#$text_hex/g" \
|
||||
"$sddm_template" | sudo tee "$sddm_dir/Main.qml" >/dev/null
|
||||
|
||||
sudo cp "$logo_path" "$sddm_dir/logo.png"
|
||||
sudo cp "$staging_dir/logo.png" "$sddm_dir/logo.png"
|
||||
for asset in bullet.png entry.png lock.png; do
|
||||
sudo cp "$staging_dir/$asset" "$sddm_dir/$asset"
|
||||
done
|
||||
|
||||
@@ -141,7 +141,8 @@ say() {
|
||||
notice() {
|
||||
clear_logo
|
||||
echo
|
||||
gum spin --spinner "pulse" --title "$1" -- sleep "${2:-2}"
|
||||
printf '%*s\033[32m● \033[0m%s\n' "$PADDING_LEFT" '' "$1"
|
||||
sleep "${2:-2}"
|
||||
echo
|
||||
}
|
||||
|
||||
@@ -676,6 +677,11 @@ user_groups() {
|
||||
if [[ -f $PROVISIONING_DIR/groups ]]; then
|
||||
while IFS= read -r group; do
|
||||
[[ -n $group ]] || continue
|
||||
# Never grant docker at first boot, even if an older install recorded it
|
||||
# (or a factory snapshot predating the opt-in default carries it): the
|
||||
# docker group is root-equivalent. It is opt-in via
|
||||
# omarchy-setup-security-sudoless-docker.
|
||||
[[ $group == "docker" ]] && continue
|
||||
getent group "$group" >/dev/null || continue
|
||||
[[ ",$groups," == *",$group,"* ]] || groups+=",$group"
|
||||
done <"$PROVISIONING_DIR/groups"
|
||||
|
||||
@@ -24,9 +24,14 @@ echo -e "\e[32mRemoving FIDO2 device from authentication.\n\e[0m"
|
||||
|
||||
remove_pam_config
|
||||
|
||||
if [[ -d /etc/fido2 ]]; then
|
||||
authdir=/etc/fido2
|
||||
|
||||
# -d follows symlinks, so a dangling link at /etc/fido2 would survive this and
|
||||
# a later setup would install the authfile through it. rm -rf on a symlink
|
||||
# removes the link itself, never the directory it points at.
|
||||
if [[ -e $authdir || -L $authdir ]]; then
|
||||
echo "Removing FIDO2 configuration..."
|
||||
sudo rm -rf /etc/fido2
|
||||
sudo rm -rf "$authdir"
|
||||
fi
|
||||
|
||||
echo "Removing FIDO2 packages..."
|
||||
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Disable sudoless Docker by removing your user from the docker group
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
set -e
|
||||
|
||||
# Ask about the configured groups, not this session's: right after enabling,
|
||||
# sudoless Docker is on for the account even though the running session still
|
||||
# needs a prompt, and this command is what turns it back off.
|
||||
if omarchy-sudo-docker --configured; then
|
||||
echo "Sudoless Docker is not enabled: $USER is not in the docker group."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Removing $USER from the docker group..."
|
||||
sudo gpasswd -d "$USER" docker >/dev/null
|
||||
|
||||
# Group membership is only re-read by a fresh session, and in practice logging
|
||||
# out or newgrp isn't enough — only a reboot reliably applies it. Record it so a
|
||||
# later `omarchy update` still prompts (omarchy-update-restart reads this), then
|
||||
# offer to do it now.
|
||||
omarchy-state set reboot-required
|
||||
|
||||
echo ""
|
||||
echo "Sudoless Docker DISABLED. Docker access goes through a polkit/sudo prompt"
|
||||
echo "again: the Docker TUI (Super + Shift + D) and the Windows VM ask when they"
|
||||
echo "need it, and the plain 'docker' CLI runs under sudo. It takes effect after a reboot."
|
||||
echo ""
|
||||
# The migration reuses this command during 'omarchy update' and defers the
|
||||
# reboot to omarchy-update-restart, so it doesn't cut the update short.
|
||||
if [[ -z ${OMARCHY_DEFER_REBOOT:-} ]] && gum confirm "Reboot now to apply?"; then
|
||||
omarchy-system-reboot
|
||||
fi
|
||||
@@ -63,7 +63,6 @@ relock_session() {
|
||||
|
||||
# Each kill stops the oldest matching instance and only returns once it has
|
||||
# fully exited, so the no-duplicate launch below can't race a dying shell.
|
||||
# Requires our quickshell-git build; 0.3.0's kill returns immediately.
|
||||
while timeout 5 quickshell kill -p "$CONFIG_DIR" --any-display >/dev/null 2>&1; do :; done
|
||||
|
||||
# Spawn from Hyprland so the shell inherits the canonical session environment,
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
|
||||
check_fido2_hardware() {
|
||||
@@ -50,13 +51,79 @@ if ! check_fido2_hardware; then
|
||||
fi
|
||||
|
||||
# Create the pamu2fcfg file
|
||||
if [[ ! -f /etc/fido2/fido2 ]]; then
|
||||
sudo mkdir -p /etc/fido2
|
||||
authdir=/etc/fido2
|
||||
authfile=/etc/fido2/fido2
|
||||
|
||||
# install -d follows a symlink here and applies the mode and ownership to
|
||||
# whatever it points at, so the credential would be staged and published inside
|
||||
# the link target and that directory reopened to root:root 755. This is the
|
||||
# threat omarchy-remove-security-fido2 already names on its side.
|
||||
if [[ -L $authdir || ( -e $authdir && ! -d $authdir ) ]]; then
|
||||
echo -e "\e[31m\n$authdir is not a FIDO2 configuration directory.\e[0m"
|
||||
echo "Run omarchy-remove-security-fido2 first, then set FIDO2 up again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# -f follows symlinks, so the already-registered check below reads a symlinked
|
||||
# authfile as a registration and leaves it in place, and is false for a
|
||||
# directory, so it tries to register over one. Only a regular file is a valid
|
||||
# pam_u2f authfile.
|
||||
if [[ -L $authfile || ( -e $authfile && ! -f $authfile ) ]]; then
|
||||
echo -e "\e[31m\n$authfile is not a FIDO2 registration file.\e[0m"
|
||||
echo "Run omarchy-remove-security-fido2 first, then set FIDO2 up again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f $authfile ]]; then
|
||||
sudo install -d -m 755 -o root -g root "$authdir"
|
||||
echo -e "\e[32m\nLet's setup your device by confirming on the device now.\e[0m"
|
||||
echo -e "Touch your FIDO2 key when it lights up...\n"
|
||||
|
||||
if pamu2fcfg >/tmp/fido2; then
|
||||
sudo mv /tmp/fido2 /etc/fido2/fido2
|
||||
# A unique sibling created by root cannot be replaced by another process
|
||||
# running as this user. Stream pamu2fcfg into it instead of asking root to
|
||||
# reopen a caller-owned path: an observed temporary name could otherwise be
|
||||
# replaced with a symlink before the privileged copy. The final rename is
|
||||
# atomic, and -T refuses a directory at the destination. Mode 644 keeps the
|
||||
# root-owned global authfile readable when pam_u2f uses openasuser; only root
|
||||
# can still rewrite it.
|
||||
stage=""
|
||||
|
||||
# mktemp's output is an operand for four privileged commands below, one of
|
||||
# them an rm. Take only the name this script asked for rather than whatever
|
||||
# came back on stdout.
|
||||
safe_stage_path() {
|
||||
local candidate=$1
|
||||
local prefix="$authfile.new."
|
||||
local suffix
|
||||
|
||||
[[ $candidate == "$prefix"* ]] || return 1
|
||||
suffix=${candidate#"$prefix"}
|
||||
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
|
||||
}
|
||||
|
||||
cleanup_stage() {
|
||||
local status=$?
|
||||
|
||||
if safe_stage_path "$stage"; then
|
||||
sudo rm -f -- "$stage" || true
|
||||
fi
|
||||
|
||||
return "$status"
|
||||
}
|
||||
|
||||
trap cleanup_stage EXIT
|
||||
stage=$(sudo mktemp "$authfile.new.XXXXXX")
|
||||
|
||||
if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then
|
||||
echo -e "\e[31m\nCould not create a safe staging file beside $authfile.\e[0m"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if pamu2fcfg | sudo tee "$stage" >/dev/null && [[ -s $stage ]]; then
|
||||
sudo chmod 644 "$stage"
|
||||
sudo mv -Tf "$stage" "$authfile"
|
||||
stage=""
|
||||
trap - EXIT
|
||||
echo -e "\e[32mFIDO2 device registered successfully!\e[0m"
|
||||
else
|
||||
echo -e "\e[31m\nFIDO2 registration failed. Please try again.\e[0m"
|
||||
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Enable sudoless Docker by adding your user to the docker group (root-equivalent!)
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
set -e
|
||||
|
||||
# Ask about the configured groups, not this session's: once enabled it stays
|
||||
# enabled for the account even before the reboot that lets this session use it.
|
||||
if ! omarchy-sudo-docker --configured; then
|
||||
echo "Sudoless Docker is already enabled: $USER is in the docker group."
|
||||
echo "To disable it again, run: omarchy-remove-security-sudoless-docker"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "⚠️ WARNING: Enabling sudoless Docker adds you to the 'docker' group."
|
||||
echo ""
|
||||
echo "The Docker daemon runs as root, so membership in the docker group is"
|
||||
echo "equivalent to passwordless root. Any process running as your user could"
|
||||
echo "then run, for example:"
|
||||
echo ""
|
||||
echo " docker run -v /:/host alpine # full read/write of the host, as root"
|
||||
echo ""
|
||||
echo "and take over the machine with no password prompt. A single rogue script,"
|
||||
echo "dependency, or plugin running as you is enough. It is convenient for"
|
||||
echo "development, but it removes the protection Omarchy keeps by default, where"
|
||||
echo "Docker access goes through a polkit/sudo prompt."
|
||||
echo ""
|
||||
|
||||
if gum confirm "Enable sudoless Docker? This gives anything running as you passwordless root."; then
|
||||
sudo usermod -aG docker "$USER"
|
||||
# A new docker group membership is only picked up by a fresh session, and in
|
||||
# practice logging out or newgrp isn't enough — only a reboot reliably applies
|
||||
# it. Record it so a later `omarchy update` still prompts
|
||||
# (omarchy-update-restart reads this), then offer to do it now.
|
||||
omarchy-state set reboot-required
|
||||
echo ""
|
||||
echo "Sudoless Docker ENABLED. It takes effect after a reboot."
|
||||
echo "To disable it again: Setup > Security > Sudoless Docker."
|
||||
echo ""
|
||||
# The migration reuses this command during 'omarchy update' and defers the
|
||||
# reboot to omarchy-update-restart, so it doesn't cut the update short.
|
||||
if [[ -z ${OMARCHY_DEFER_REBOOT:-} ]] && gum confirm "Reboot now to apply?"; then
|
||||
omarchy-system-reboot
|
||||
fi
|
||||
else
|
||||
echo "Aborted. No changes made. Docker access still goes through a prompt."
|
||||
fi
|
||||
+15
-3
@@ -1,6 +1,18 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Display a "Done!" message with a spinner and wait for user to press any key.
|
||||
# omarchy:summary=Display a "Done!" message and wait for user to press any key.
|
||||
|
||||
echo
|
||||
gum spin --spinner "globe" --title "Done! Press any key to close..." -- bash -c 'read -n 1 -s'
|
||||
# The device node is there whether or not a terminal is behind it, so opening
|
||||
# it is the only test that means anything.
|
||||
: 2>/dev/null <>/dev/tty || exit 0
|
||||
|
||||
# gum 2.0 no longer lets a spun command read the terminal, so the wait has to
|
||||
# happen here. Its own query replies are still queued on the tty; drop those or
|
||||
# they answer the keypress for the user.
|
||||
while read -rsn 1 -t 0.1 _ </dev/tty; do :; done
|
||||
|
||||
# Prompt on the terminal rather than stdout, or a caller that redirects us
|
||||
# leaves the user waiting on a prompt they were never shown.
|
||||
printf '\n\033[32m● \033[0mDone! Press any key to close...' >/dev/tty
|
||||
read -rsn 1 </dev/tty
|
||||
echo >/dev/tty
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Succeed when Docker needs sudo, fail when it can be used directly
|
||||
# omarchy:args=[--configured]
|
||||
# omarchy:examples=omarchy-sudo-docker && echo "needs sudo" | omarchy-sudo-docker --configured
|
||||
# omarchy:hidden=true
|
||||
|
||||
# The docker group is root-equivalent, so Omarchy leaves users out of it by
|
||||
# default and reaches the daemon through a prompt instead. Everything that has
|
||||
# to make that choice asks here rather than testing group membership itself.
|
||||
#
|
||||
# Two questions, because they have different answers between toggling sudoless
|
||||
# Docker and the reboot that applies it (group membership is fixed when the
|
||||
# session is created):
|
||||
#
|
||||
# (default) Does Docker need sudo *right now*? Answered by whether this
|
||||
# process can actually reach the socket, which is what decides
|
||||
# if a command must elevate. Still true in the window after
|
||||
# sudoless Docker is enabled but before the reboot.
|
||||
# --configured Will it need sudo once the account's groups take effect?
|
||||
# Answered from the account's configured groups, so the menu
|
||||
# offers the toggle that can actually change state.
|
||||
#
|
||||
# Succeeds (exit 0) when sudo is needed, so it reads as `if omarchy-sudo-docker`.
|
||||
|
||||
DOCKER_SOCKET="${OMARCHY_DOCKER_SOCKET:-/var/run/docker.sock}"
|
||||
|
||||
case "${1:-}" in
|
||||
--configured)
|
||||
# An account in the docker group will not need sudo after the next login.
|
||||
id -nG "$USER" 2>/dev/null | grep -qw docker && exit 1
|
||||
exit 0
|
||||
;;
|
||||
"")
|
||||
# A socket we can write is a daemon we can drive without elevating. A missing
|
||||
# socket counts as needing sudo: reaching it means starting it as root anyway.
|
||||
[[ -w $DOCKER_SOCKET ]] && exit 1
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Usage: omarchy-sudo-docker [--configured]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -1,5 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Reset the sudo lockout/faillock for the current user.
|
||||
|
||||
su -c "faillock --reset --user $USER"
|
||||
@@ -59,7 +59,7 @@ claim_path() {
|
||||
announce() {
|
||||
local path="$1"
|
||||
local name="${path##*/}"
|
||||
local args=("Received $name" "Saved to ${dir/#$HOME/~}")
|
||||
local args=("Received $name" "Saved to ${dir/#$HOME/~}" -u critical)
|
||||
|
||||
case "${name,,}" in
|
||||
*.png | *.jpg | *.jpeg | *.gif | *.webp | *.avif | *.bmp | *.tif | *.tiff)
|
||||
@@ -72,7 +72,7 @@ announce() {
|
||||
|
||||
# Announcing is best-effort: the file is already delivered, and under `set -e`
|
||||
# a notification outage would otherwise kill the long-running receiver.
|
||||
omarchy-notification-send "${args[@]}" --exec "$(printf 'xdg-open %q' "$path")" || true
|
||||
omarchy-notification-send "${args[@]}" --exec xdg-open "$path" || true
|
||||
}
|
||||
|
||||
deliver() {
|
||||
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=List the user-installed themes that came from a git clone
|
||||
# omarchy:examples=omarchy theme extras
|
||||
|
||||
# Exits nonzero when there are none, so a caller can ask whether any exist
|
||||
# without reading the list. A symlinked theme is someone's working copy and a
|
||||
# `.git` file is a worktree pointing elsewhere; neither is ours to pull.
|
||||
status=1
|
||||
|
||||
for theme in ~/.config/omarchy/themes/*; do
|
||||
[[ ! -L $theme && -d $theme/.git ]] || continue
|
||||
echo "$theme"
|
||||
status=0
|
||||
done
|
||||
|
||||
exit $status
|
||||
@@ -16,24 +16,38 @@ if [[ -z $REPO_URL ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Refuse a URL that names a git option or a transport helper before cloning. The
|
||||
# check is shared with omarchy-plugin-add and explains itself; a missing checker
|
||||
# leaves this non-zero, which refuses the URL rather than cloning it.
|
||||
omarchy-git-url-check "$REPO_URL" || exit 1
|
||||
|
||||
THEMES_DIR="$HOME/.config/omarchy/themes"
|
||||
|
||||
# Strip user@host: prefix from scp-style SSH URLs so basename sees just the path
|
||||
REPO_PATH="$REPO_URL"
|
||||
[[ $REPO_PATH != *"://"* && $REPO_PATH == *:*/* ]] && REPO_PATH="${REPO_PATH#*:}"
|
||||
THEME_NAME=$(basename "$REPO_PATH" .git | sed -E 's/^omarchy-//; s/-theme$//' | tr '[:upper:]' '[:lower:]')
|
||||
THEME_NAME=$(basename -- "$REPO_PATH" .git | sed -E 's/^omarchy-//; s/-theme$//' | tr '[:upper:]' '[:lower:]')
|
||||
THEME_PATH="$THEMES_DIR/$THEME_NAME"
|
||||
|
||||
# The name comes from the URL and is joined into a path that is about to be
|
||||
# removed, so a repo called `..` would take ~/.config/omarchy with it. A leading
|
||||
# dot is refused with it: `host:-s/foo.git` leaves basename with `.git`.
|
||||
if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then
|
||||
echo "Error: '$REPO_URL' does not give a usable theme name."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Remove existing theme if present
|
||||
if [[ -d $THEME_PATH ]]; then
|
||||
rm -rf "$THEME_PATH"
|
||||
fi
|
||||
|
||||
# Clone the repo directly to ~/.config/omarchy/themes
|
||||
if ! git clone "$REPO_URL" "$THEME_PATH"; then
|
||||
if ! git clone -- "$REPO_URL" "$THEME_PATH"; then
|
||||
echo "Error: Failed to clone theme repo."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Apply the new theme with omarchy-theme-set
|
||||
omarchy-theme-set $THEME_NAME
|
||||
# Apply the new theme with omarchy-theme-set, which stages only the files an
|
||||
# extra theme is allowed to contribute and names anything it dropped.
|
||||
omarchy-theme-set "$THEME_NAME"
|
||||
@@ -21,8 +21,9 @@ fi
|
||||
THEMES_DIR="$HOME/.config/omarchy/themes"
|
||||
THEME_PATH="$THEMES_DIR/$THEME_NAME"
|
||||
|
||||
# Ensure a theme was set
|
||||
if [[ -z $THEME_NAME ]]; then
|
||||
# Ensure a theme was set, and that the name cannot climb out of THEMES_DIR
|
||||
# on its way into the rm below.
|
||||
if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
+130
-2
@@ -17,6 +17,19 @@ THEME_SET_LOCK="${XDG_RUNTIME_DIR:-/tmp}/omarchy-theme-set.lock"
|
||||
USER_THEMES_PATH="$HOME/.config/omarchy/themes"
|
||||
OMARCHY_THEMES_PATH="$OMARCHY_PATH/themes"
|
||||
|
||||
# What a theme installed from a git repo may not ship, because these run code.
|
||||
# Hyprland requires a theme's hyprland.lua and gum_env.lua at login and Neovim
|
||||
# loads its neovim.lua at startup, so no .lua from such a theme is staged at all.
|
||||
# Each terminal config names the program the terminal launches, and vscode.json
|
||||
# names an extension omarchy-theme-set-vscode installs, a VS Code extension being
|
||||
# arbitrary JavaScript. Everything else a theme ships is colour and is kept.
|
||||
#
|
||||
# Adding a template for another terminal, or for another editor that loads Lua,
|
||||
# means adding it here. test/shell.d/theme-staging-test.sh fails on a generated
|
||||
# theme file that is neither denied here nor recorded there as colour-only.
|
||||
INSTALLED_THEME_DENIED=(alacritty.toml foot.ini ghostty.conf kitty.conf vscode.json)
|
||||
IGNORED_THEME_FILES=()
|
||||
|
||||
run_parallel() {
|
||||
local pid
|
||||
local pids=()
|
||||
@@ -122,12 +135,121 @@ set_theme_background() {
|
||||
ln -nsf "$new_background" "$CURRENT_BACKGROUND_LINK"
|
||||
}
|
||||
|
||||
is_denied_installed_file() {
|
||||
local name="$1"
|
||||
local denied
|
||||
|
||||
[[ $name == *.lua ]] && return 0
|
||||
|
||||
for denied in "${INSTALLED_THEME_DENIED[@]}"; do
|
||||
[[ $name == "$denied" ]] && return 0
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# Themes older than colors.toml still get their palette, but their
|
||||
# alacritty.toml never reaches the staged theme: an Alacritty config names the
|
||||
# program the terminal launches.
|
||||
stage_installed_colors_from_alacritty() {
|
||||
local source="$1"
|
||||
local scratch
|
||||
|
||||
if [[ -f $NEXT_THEME_PATH/colors.toml ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ ! -f $source/alacritty.toml || -L $source/alacritty.toml ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
scratch=$(mktemp -d)
|
||||
cp "$source/alacritty.toml" "$scratch/alacritty.toml"
|
||||
omarchy-theme-colors-from-alacritty "$scratch"
|
||||
|
||||
if [[ -f $scratch/colors.toml ]]; then
|
||||
cp "$scratch/colors.toml" "$NEXT_THEME_PATH/colors.toml"
|
||||
fi
|
||||
|
||||
rm -rf "$scratch"
|
||||
}
|
||||
|
||||
# Copies a directory without ever following a symlink: in an installed theme one
|
||||
# points wherever the theme author chose, which is how an unlock.png becomes a
|
||||
# copy of any file the session can read.
|
||||
stage_installed_dir() {
|
||||
local source="$1"
|
||||
local dest="$2"
|
||||
local entry name
|
||||
|
||||
mkdir -p "$dest"
|
||||
|
||||
for entry in "$source"/*; do
|
||||
[[ -e $entry && ! -L $entry ]] || continue
|
||||
name=${entry##*/}
|
||||
|
||||
if [[ -d $entry ]]; then
|
||||
stage_installed_dir "$entry" "$dest/$name"
|
||||
else
|
||||
cp "$entry" "$dest/$name"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# `omarchy theme install` clones into ~/.config/omarchy/themes, so a .git
|
||||
# directory there means the contents came from a stranger and are held to the
|
||||
# list above. A directory the user wrote themselves, and a symlink to their own
|
||||
# working copy, are theirs to fill however they like -- the same distinction
|
||||
# omarchy-theme-extras draws when it decides which themes it may pull.
|
||||
theme_came_from_a_repo() {
|
||||
local source="$1"
|
||||
|
||||
[[ ! -L $source && -d $source/.git ]]
|
||||
}
|
||||
|
||||
stage_installed_theme() {
|
||||
local source="$1"
|
||||
local entry name
|
||||
|
||||
[[ -d $source ]] || return 0
|
||||
|
||||
for entry in "$source"/*; do
|
||||
[[ -e $entry ]] || continue
|
||||
name=${entry##*/}
|
||||
|
||||
if [[ -L $entry ]] || is_denied_installed_file "$name"; then
|
||||
case "${name,,}" in
|
||||
readme* | license* | changelog* | *.md | *.txt) ;;
|
||||
*) IGNORED_THEME_FILES+=("$name") ;;
|
||||
esac
|
||||
elif [[ -d $entry ]]; then
|
||||
stage_installed_dir "$entry" "$NEXT_THEME_PATH/$name"
|
||||
else
|
||||
cp "$entry" "$NEXT_THEME_PATH/$name"
|
||||
fi
|
||||
done
|
||||
|
||||
stage_installed_colors_from_alacritty "$source"
|
||||
}
|
||||
|
||||
report_ignored_theme_files() {
|
||||
(( ${#IGNORED_THEME_FILES[@]} > 0 )) || return 0
|
||||
|
||||
echo "Ignored in $USER_THEMES_PATH/$THEME_NAME: ${IGNORED_THEME_FILES[*]}" >&2
|
||||
echo "A theme installed from a git repo cannot supply Lua, a terminal config, or vscode.json." >&2
|
||||
}
|
||||
|
||||
THEME_NAME=$(echo "$1" | sed -E 's/<[^>]+>//g' | tr '[:upper:]' '[:lower:]' | tr ' ' '-')
|
||||
THEME_HEADLESS=0
|
||||
if [[ ${OMARCHY_THEME_HEADLESS:-} == "1" || ${OMARCHY_THEME_OFFLINE:-} == "1" ]]; then
|
||||
THEME_HEADLESS=1
|
||||
fi
|
||||
|
||||
if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then
|
||||
echo "Invalid theme name: $1"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d $OMARCHY_THEMES_PATH/$THEME_NAME ]] && [[ ! -d $USER_THEMES_PATH/$THEME_NAME ]]; then
|
||||
echo "Theme '$THEME_NAME' does not exist"
|
||||
exit 1
|
||||
@@ -143,9 +265,15 @@ flock 9
|
||||
rm -rf "$NEXT_THEME_PATH"
|
||||
mkdir -p "$NEXT_THEME_PATH"
|
||||
|
||||
# Copy official theme first, then overlay user customizations on top
|
||||
# Copy official theme first, then overlay the user's theme on top
|
||||
cp -r "$OMARCHY_THEMES_PATH/$THEME_NAME/"* "$NEXT_THEME_PATH/" 2>/dev/null
|
||||
cp -r "$USER_THEMES_PATH/$THEME_NAME/"* "$NEXT_THEME_PATH/" 2>/dev/null
|
||||
|
||||
if theme_came_from_a_repo "$USER_THEMES_PATH/$THEME_NAME"; then
|
||||
stage_installed_theme "$USER_THEMES_PATH/$THEME_NAME"
|
||||
report_ignored_theme_files
|
||||
else
|
||||
cp -r "$USER_THEMES_PATH/$THEME_NAME/"* "$NEXT_THEME_PATH/" 2>/dev/null
|
||||
fi
|
||||
|
||||
# Generate colors.toml from alacritty.toml if theme is missing colors.toml
|
||||
if [[ ! -f $NEXT_THEME_PATH/colors.toml && -f $NEXT_THEME_PATH/alacritty.toml ]]; then
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
# omarchy:summary=Update user-installed git themes
|
||||
|
||||
for dir in ~/.config/omarchy/themes/*/; do
|
||||
if [[ -d $dir ]] && [[ ! -L ${dir%/} ]] && [[ -d $dir/.git ]]; then
|
||||
echo "Updating: $(basename "$dir")"
|
||||
git -C "$dir" pull
|
||||
fi
|
||||
mapfile -t themes < <(omarchy-theme-extras)
|
||||
|
||||
for theme in "${themes[@]}"; do
|
||||
echo "Updating: $(basename "$theme")"
|
||||
git -C "$theme" pull
|
||||
done
|
||||
@@ -7,44 +7,70 @@
|
||||
KIND="${1:-}"
|
||||
ACTION="${2:-toggle}"
|
||||
|
||||
usage() {
|
||||
echo "Usage: omarchy-toggle-input-device <touchpad|touchscreen> [on|off|toggle]" >&2
|
||||
}
|
||||
|
||||
case "$KIND" in
|
||||
touchpad) LABEL="Touchpad" ICON="touchpad" ;;
|
||||
touchscreen) LABEL="Touchscreen" ICON="touch" ;;
|
||||
*)
|
||||
echo "Usage: omarchy-toggle-input-device <touchpad|touchscreen> [on|off|toggle]" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Hyprland sources this directory on reload, so the disabled state survives restarts
|
||||
STATE_FILE="$HOME/.local/state/omarchy/toggles/hypr/$KIND-disabled.lua"
|
||||
# The persisted disable is the device name stored as plain data; on every
|
||||
# reload default/hypr/disabled-input-device.lua reads it back and disables the
|
||||
# device. Names come from USB descriptors and must not be interpolated into
|
||||
# shell or Lua. The path is hardcoded to ~/.local/state like the sibling
|
||||
# toggle tools, so it keeps working when XDG_STATE_HOME diverges.
|
||||
NAME_FILE="$HOME/.local/state/omarchy/toggles/hypr/$KIND-disabled-name"
|
||||
|
||||
device="$("omarchy-hw-$KIND")"
|
||||
|
||||
if [[ -z $device ]]; then
|
||||
echo "No $KIND device found" >&2
|
||||
exit 1
|
||||
fi
|
||||
require_device() {
|
||||
if [[ -z $device ]]; then
|
||||
echo "No $KIND device found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $device == *[[:cntrl:]]* ]]; then
|
||||
echo "Invalid $KIND device name" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
apply_device() {
|
||||
local enabled=$1
|
||||
local quoted=${device//\\/\\\\}
|
||||
quoted=${quoted//\"/\\\"}
|
||||
hyprctl eval "hl.device({ name = \"$quoted\", enabled = $enabled })" >/dev/null
|
||||
}
|
||||
|
||||
enable() {
|
||||
hyprctl eval "hl.device({ name = \"$device\", enabled = true })" >/dev/null
|
||||
rm -f "$STATE_FILE"
|
||||
# Clear the persisted state before requiring a usable device, so a device
|
||||
# that stops reporting a valid name can never wedge the disable in place.
|
||||
rm -f "$NAME_FILE"
|
||||
require_device
|
||||
apply_device true
|
||||
omarchy-osd -i "$ICON" -m "$LABEL enabled"
|
||||
}
|
||||
|
||||
disable() {
|
||||
hyprctl eval "hl.device({ name = \"$device\", enabled = false })" >/dev/null
|
||||
mkdir -p "$(dirname "$STATE_FILE")"
|
||||
printf 'hl.device({ name = "%s", enabled = false })\n' "$device" >"$STATE_FILE"
|
||||
require_device
|
||||
apply_device false
|
||||
mkdir -p "$(dirname "$NAME_FILE")"
|
||||
printf '%s\n' "$device" >"$NAME_FILE"
|
||||
omarchy-osd -i "$ICON" -m "$LABEL disabled"
|
||||
}
|
||||
|
||||
case "$ACTION" in
|
||||
on) enable ;;
|
||||
off) disable ;;
|
||||
toggle) if [[ -f $STATE_FILE ]]; then enable; else disable; fi ;;
|
||||
toggle) if [[ -f $NAME_FILE ]]; then enable; else disable; fi ;;
|
||||
*)
|
||||
echo "Usage: omarchy-toggle-input-device <touchpad|touchscreen> [on|off|toggle]" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1317,9 +1317,9 @@ apply_system_transition() {
|
||||
printf '%s\n' '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \
|
||||
as_root tee /usr/lib/chromium/initial_preferences >/dev/null
|
||||
|
||||
if getent group docker >/dev/null; then
|
||||
as_root usermod -aG docker "$target_user"
|
||||
fi
|
||||
# Deliberately do NOT add the user to the docker group. That group is
|
||||
# root-equivalent, so it is opt-in now (Setup > Security > Sudoless Docker);
|
||||
# re-granting it on upgrade would silently undo that default for everyone.
|
||||
|
||||
if [[ -f /usr/bin/powerprofilesctl ]]; then
|
||||
as_root sed -i '/env python3/ c\#!/bin/python3' /usr/bin/powerprofilesctl || true
|
||||
|
||||
+487
-117
@@ -4,11 +4,371 @@
|
||||
# omarchy:args=<install|remove|launch|stop|status> [options]
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml"
|
||||
# The Windows VM runs a privileged container (KVM, /dev/net/tun, NET_ADMIN), so
|
||||
# it needs the root-owned Docker daemon. By default the user is NOT in the docker
|
||||
# group (that group is root-equivalent), so Docker access is gated behind a
|
||||
# single polkit prompt. If the user opted into sudoless Docker
|
||||
# (omarchy-setup-security-sudoless-docker), the socket is reachable directly and
|
||||
# no prompt appears.
|
||||
#
|
||||
# The compose file lives in a root-owned directory and is only ever written by
|
||||
# the elevated, input-validated write_compose action below. That is the whole
|
||||
# point: a root-invoked `docker compose up` must never consume a file that a
|
||||
# process running as the user could have rewritten to bind-mount / into the
|
||||
# container. Earlier versions kept it under ~/.config/windows, which a rogue
|
||||
# process could edit and then trigger a privileged bring-up of — a file-swap
|
||||
# path to root. Do not move it back under $HOME.
|
||||
|
||||
RUNTIME_DIR="${OMARCHY_WINDOWS_DIR:-/var/lib/omarchy/windows}"
|
||||
COMPOSE_FILE="$RUNTIME_DIR/docker-compose.yml"
|
||||
LEGACY_COMPOSE_FILE="$HOME/.config/windows/docker-compose.yml"
|
||||
# The guest password lives in the root-owned compose (readable by root and the
|
||||
# docker group), but RDP needs it as the user. Keep a private copy here, 0600 in
|
||||
# the user's own config, so the plaintext password is never world-readable.
|
||||
CREDENTIALS_FILE="$HOME/.config/windows/credentials"
|
||||
IMAGE="dockurr/windows"
|
||||
CONTAINER="omarchy-windows"
|
||||
|
||||
# --- privilege helpers -------------------------------------------------------
|
||||
|
||||
# True when this session can reach the Docker socket directly (sudoless Docker
|
||||
# on and in effect). Asking about the socket rather than the configured groups
|
||||
# keeps the prompt in place through the window where sudoless Docker is enabled
|
||||
# but the reboot that grants the group has not happened yet.
|
||||
docker_needs_sudo() { omarchy-sudo-docker; }
|
||||
|
||||
# The command to hand pkexec for the privileged re-exec. pkexec runs whatever
|
||||
# executable it is given (after authorization) and only shows the path in the
|
||||
# prompt — it does NOT require the target to be root-owned. So resolve to the
|
||||
# packaged command and refuse to elevate anything a non-root user could have
|
||||
# written: a PATH-injected shim, or a user-owned dev checkout. Without this, a
|
||||
# prompt the user grants for the trusted helper could run an attacker's binary
|
||||
# as root. Fails closed (empty output) when no trustworthy target is found.
|
||||
priv_target() {
|
||||
local candidate owner mode
|
||||
for candidate in /usr/bin/omarchy-windows-vm "$(type -P omarchy-windows-vm 2>/dev/null)"; do
|
||||
[[ -n $candidate && -x $candidate ]] || continue
|
||||
owner=$(stat -Lc '%u' "$candidate" 2>/dev/null) || continue
|
||||
mode=$(stat -Lc '%a' "$candidate" 2>/dev/null) || continue
|
||||
[[ $owner == "0" ]] || continue
|
||||
((8#$mode & 022)) && continue # writable by group or other -> reject
|
||||
printf '%s\n' "$candidate"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Run a privileged VM action. write_compose always elevates (the compose is
|
||||
# root-owned); the daemon operations run directly when sudoless Docker is on and
|
||||
# otherwise behind a polkit prompt. The stock org.freedesktop.policykit.exec
|
||||
# policy is auth_admin (not auth_admin_keep), so each elevated action prompts:
|
||||
# a launch asks once to start and, unless authorization is still cached by the
|
||||
# agent, again to stop.
|
||||
priv() {
|
||||
local action="$1"
|
||||
shift
|
||||
if [[ $action != write_compose ]] && ! docker_needs_sudo; then
|
||||
"__priv_$action" "$@"
|
||||
return
|
||||
fi
|
||||
local target
|
||||
target=$(priv_target) || {
|
||||
echo "omarchy-windows-vm: refusing to run a non-root-owned command as root" >&2
|
||||
return 1
|
||||
}
|
||||
pkexec "$target" __priv "$action" "$@"
|
||||
}
|
||||
|
||||
dc() { docker-compose -f "$COMPOSE_FILE" "$@"; }
|
||||
|
||||
# --- validation (shared by the user-side prompts and the root-side writer) ----
|
||||
|
||||
valid_ram() { [[ $1 =~ ^[0-9]{1,3}G$ ]]; }
|
||||
valid_cores() { [[ $1 =~ ^[0-9]{1,2}$ ]] && ((10#$1 >= 1)); }
|
||||
valid_disk() { [[ $1 =~ ^[0-9]{1,4}G$ ]]; }
|
||||
valid_username() { [[ $1 =~ ^[A-Za-z0-9_-]{1,20}$ ]]; }
|
||||
valid_tz() { [[ $1 =~ ^[A-Za-z0-9_/.+-]{1,64}$ ]]; }
|
||||
valid_path() {
|
||||
[[ $1 =~ ^/[A-Za-z0-9._/-]+$ ]] || return 1
|
||||
# Reject non-normalized paths: a . or .. component canonicalizes at mount time
|
||||
# (e.g. /./ or /a/../ -> /), which would bind-mount a sensitive directory —
|
||||
# host / included — into the guest. Volumes must be given already-normalized.
|
||||
case "$1" in
|
||||
*//* | */./* | */../* | */. | */..) return 1 ;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
valid_password() { [[ $1 =~ ^[[:print:]]{1,64}$ ]]; }
|
||||
|
||||
# The only privileged sub-actions __priv may dispatch. A bash command name
|
||||
# containing a slash is executed as a path, so validating the action here — not
|
||||
# just interpolating it into "__priv_${action}" — is what stops an action like
|
||||
# ../tmp/evil from running an arbitrary file as root.
|
||||
valid_priv_action() {
|
||||
case "$1" in
|
||||
write_compose | up | up_wait | down | status | remove) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# --- privileged actions (run as root via pkexec, or directly when sudoless) ---
|
||||
|
||||
# Reads KEY=VALUE lines on stdin, re-validates every field, and writes the
|
||||
# compose atomically as root. Re-validation here is the security boundary: the
|
||||
# writer refuses rather than emit a compose an attacker could have influenced.
|
||||
# Only these fixed keys are honored; image, container name, devices, caps, and
|
||||
# port bindings are hard-coded and never taken from input.
|
||||
__priv_write_compose() {
|
||||
local ram cores disk username password tz storage shared key value
|
||||
|
||||
while IFS='=' read -r key value; do
|
||||
case "$key" in
|
||||
RAM) ram="$value" ;;
|
||||
CORES) cores="$value" ;;
|
||||
DISK) disk="$value" ;;
|
||||
USERNAME) username="$value" ;;
|
||||
PASSWORD) password="$value" ;;
|
||||
TZ) tz="$value" ;;
|
||||
STORAGE) storage="$value" ;;
|
||||
SHARED) shared="$value" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
valid_ram "$ram" || { echo "invalid RAM: $ram" >&2; exit 2; }
|
||||
valid_cores "$cores" || { echo "invalid CPU cores: $cores" >&2; exit 2; }
|
||||
valid_disk "$disk" || { echo "invalid disk size: $disk" >&2; exit 2; }
|
||||
valid_username "$username" || { echo "invalid username: $username" >&2; exit 2; }
|
||||
valid_password "$password" || { echo "invalid password" >&2; exit 2; }
|
||||
valid_tz "$tz" || tz="UTC"
|
||||
valid_path "$storage" || { echo "invalid storage path: $storage" >&2; exit 2; }
|
||||
valid_path "$shared" || { echo "invalid shared path: $shared" >&2; exit 2; }
|
||||
|
||||
# Neutralize anything in the password that could be misread when the compose
|
||||
# is parsed. Two layers apply, in this order at parse time: docker compose
|
||||
# variable interpolation over the raw text ($VAR / $$), then YAML parsing of
|
||||
# the double-quoted scalar. Encode for the inner layer first (backslash, then
|
||||
# double-quote) and the interpolation layer last ($ -> $$), so a password
|
||||
# containing " \ or $ reaches the guest verbatim. unescape() reverses this in
|
||||
# the opposite order for the RDP credentials.
|
||||
local esc_password=${password//\\/\\\\}
|
||||
esc_password=${esc_password//\"/\\\"}
|
||||
esc_password=${esc_password//\$/\$\$}
|
||||
|
||||
mkdir -p "$RUNTIME_DIR"
|
||||
chmod 0755 "$RUNTIME_DIR" 2>/dev/null || true
|
||||
chown root:root "$RUNTIME_DIR" 2>/dev/null || true
|
||||
|
||||
local tmp
|
||||
tmp=$(mktemp "$RUNTIME_DIR/.compose.XXXXXX")
|
||||
cat >"$tmp" <<EOF
|
||||
services:
|
||||
windows:
|
||||
image: $IMAGE
|
||||
container_name: $CONTAINER
|
||||
environment:
|
||||
VERSION: "11"
|
||||
RAM_SIZE: "$ram"
|
||||
CPU_CORES: "$cores"
|
||||
DISK_SIZE: "$disk"
|
||||
USERNAME: "$username"
|
||||
PASSWORD: "$esc_password"
|
||||
TZ: "$tz"
|
||||
ARGUMENTS: "-rtc base=localtime,clock=host,driftfix=slew"
|
||||
devices:
|
||||
- /dev/kvm
|
||||
- /dev/net/tun
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
ports:
|
||||
- 127.0.0.1:8006:8006
|
||||
- 127.0.0.1:3389:3389/tcp
|
||||
- 127.0.0.1:3389:3389/udp
|
||||
volumes:
|
||||
- $storage:/storage
|
||||
- $shared:/shared
|
||||
restart: "no"
|
||||
stop_grace_period: 2m
|
||||
EOF
|
||||
# Readable by root and the docker group only. In sudoless mode the user is in
|
||||
# the docker group and runs docker-compose against this file directly; in the
|
||||
# default mode the elevated helper (root) reads it, and other local users
|
||||
# cannot read the guest password. chown falls back to root:root if the docker
|
||||
# group somehow does not exist (sudoless mode could not be enabled anyway).
|
||||
chmod 0640 "$tmp" 2>/dev/null || true
|
||||
chown root:docker "$tmp" 2>/dev/null || chown root:root "$tmp" 2>/dev/null || true
|
||||
mv -f "$tmp" "$COMPOSE_FILE"
|
||||
}
|
||||
|
||||
# Read the host source of a bind mount out of the compose (e.g. /storage).
|
||||
# valid_path kept a ':' out of the stored path, so splitting on it is safe.
|
||||
get_mount_source() {
|
||||
sed -n "s|^[[:space:]]*-[[:space:]]*\(/[^:]*\):$1\$|\1|p" "$COMPOSE_FILE" | head -n1
|
||||
}
|
||||
|
||||
# Refuse to bring the VM up if a bind-mount source is a symlink, or reached
|
||||
# through one. valid_path keeps a traversal string like /./ out of the compose,
|
||||
# but a symlink planted at ~/.windows or ~/Windows would redirect the privileged
|
||||
# mount just the same — docker follows it — and a string check cannot see that.
|
||||
# So verify the real directories here, as root, immediately before the mount. A
|
||||
# source that does not exist is fine: docker creates it as a plain directory.
|
||||
assert_mounts_safe() {
|
||||
local mnt src real
|
||||
for mnt in /storage /shared; do
|
||||
src=$(get_mount_source "$mnt")
|
||||
[[ -n $src ]] || {
|
||||
echo "omarchy-windows-vm: missing $mnt mount source in the compose" >&2
|
||||
return 1
|
||||
}
|
||||
if [[ -L $src ]]; then
|
||||
echo "omarchy-windows-vm: refusing to start — $src is a symlink; the VM mount source must be a real directory" >&2
|
||||
return 1
|
||||
fi
|
||||
if [[ -e $src ]]; then
|
||||
[[ -d $src ]] || {
|
||||
echo "omarchy-windows-vm: refusing to start — $src is not a directory" >&2
|
||||
return 1
|
||||
}
|
||||
real=$(realpath "$src" 2>/dev/null)
|
||||
[[ $real == "$src" ]] || {
|
||||
echo "omarchy-windows-vm: refusing to start — $src resolves through a symlink to $real" >&2
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
__priv_up() { assert_mounts_safe && dc up -d; }
|
||||
|
||||
__priv_down() { dc down; }
|
||||
|
||||
# Bring the VM up and wait until the guest reports it is ready, all under a
|
||||
# single elevation so the readiness poll does not prompt on every iteration.
|
||||
__priv_up_wait() {
|
||||
assert_mounts_safe || return 1
|
||||
local status
|
||||
status=$(docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null)
|
||||
if [[ $status != "running" ]]; then
|
||||
dc up -d || return 1
|
||||
fi
|
||||
|
||||
# docker logs persists across restarts, so anchor the scan to the current
|
||||
# start time; an empty --since would match a stale "started successfully".
|
||||
local started_at count=0
|
||||
while true; do
|
||||
started_at=$(docker inspect --format='{{.State.StartedAt}}' "$CONTAINER" 2>/dev/null)
|
||||
if [[ -n $started_at ]] && docker logs --since "$started_at" "$CONTAINER" 2>&1 | grep -qi "windows started successfully"; then
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
((++count > 60)) && {
|
||||
echo "Timeout: Windows VM did not report ready within 2 minutes" >&2
|
||||
return 1
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
# Print the status (empty if the container does not exist) and always succeed,
|
||||
# so a non-zero exit from priv status means the elevation itself failed
|
||||
# (authorization declined) rather than "no such container".
|
||||
__priv_status() { docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null || true; }
|
||||
|
||||
__priv_remove() {
|
||||
dc down 2>/dev/null || true
|
||||
docker rmi "$IMAGE" 2>/dev/null || true
|
||||
rm -f "$COMPOSE_FILE"
|
||||
rmdir "$RUNTIME_DIR" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# --- config helpers ----------------------------------------------------------
|
||||
|
||||
# Feed the collected settings to the elevated writer.
|
||||
write_compose() {
|
||||
local ram="$1" cores="$2" disk="$3" username="$4" password="$5" tz="$6" storage="$7" shared="$8"
|
||||
printf 'RAM=%s\nCORES=%s\nDISK=%s\nUSERNAME=%s\nPASSWORD=%s\nTZ=%s\nSTORAGE=%s\nSHARED=%s\n' \
|
||||
"$ram" "$cores" "$disk" "$username" "$password" "$tz" "$storage" "$shared" |
|
||||
priv write_compose
|
||||
}
|
||||
|
||||
# Reverse, in the opposite order, the escaping __priv_write_compose applied to
|
||||
# the password: undo the interpolation layer ($$ -> $) first, then the YAML
|
||||
# layer (\" -> ", then \\ -> \).
|
||||
unescape() {
|
||||
local v=$1
|
||||
v=${v//\$\$/\$}
|
||||
v=${v//\\\"/\"}
|
||||
v=${v//\\\\/\\}
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
# Store the RDP credentials privately for the user (0600) so the plaintext
|
||||
# password is not world-readable. The password is one validated printable line
|
||||
# (no newline), so plain KEY=VALUE is safe.
|
||||
write_credentials() {
|
||||
local username="$1" password="$2" old_umask
|
||||
mkdir -p "$(dirname "$CREDENTIALS_FILE")"
|
||||
old_umask=$(umask)
|
||||
umask 077
|
||||
printf 'USERNAME=%s\nPASSWORD=%s\n' "$username" "$password" >"$CREDENTIALS_FILE"
|
||||
chmod 600 "$CREDENTIALS_FILE" 2>/dev/null || true
|
||||
umask "$old_umask"
|
||||
}
|
||||
|
||||
# Read one field from the private credentials file; IFS on the first = keeps a
|
||||
# password that itself contains =.
|
||||
read_credential() {
|
||||
local want="$1" key value
|
||||
[[ -f $CREDENTIALS_FILE ]] || return 1
|
||||
while IFS='=' read -r key value; do
|
||||
[[ $key == "$want" ]] && {
|
||||
printf '%s' "$value"
|
||||
return 0
|
||||
}
|
||||
done <"$CREDENTIALS_FILE"
|
||||
return 1
|
||||
}
|
||||
|
||||
read_compose_value() {
|
||||
local key="$1" file="$2"
|
||||
sed -n "s/.*${key}: \"\(.*\)\"/\1/p" "$file" | head -n1
|
||||
}
|
||||
|
||||
# Older installs kept the compose under ~/.config/windows. Carry those settings
|
||||
# into the root-owned location (preserving the VM's data via the same volume
|
||||
# paths) so an upgrade does not strand or re-download an existing VM.
|
||||
migrate_legacy_compose() {
|
||||
[[ -f $COMPOSE_FILE ]] && return 0
|
||||
[[ -f $LEGACY_COMPOSE_FILE ]] || return 1
|
||||
|
||||
echo "Migrating Windows VM configuration to $COMPOSE_FILE ..."
|
||||
local ram cores disk username password tz storage shared
|
||||
ram=$(read_compose_value RAM_SIZE "$LEGACY_COMPOSE_FILE")
|
||||
cores=$(read_compose_value CPU_CORES "$LEGACY_COMPOSE_FILE")
|
||||
disk=$(read_compose_value DISK_SIZE "$LEGACY_COMPOSE_FILE")
|
||||
username=$(read_compose_value USERNAME "$LEGACY_COMPOSE_FILE")
|
||||
password=$(read_compose_value PASSWORD "$LEGACY_COMPOSE_FILE")
|
||||
tz=$(read_compose_value TZ "$LEGACY_COMPOSE_FILE")
|
||||
# The VM's data always lived in the user's own ~/.windows and ~/Windows; the
|
||||
# old compose only ever recorded those. Reconstruct them from $HOME (trusted —
|
||||
# this runs as the user) rather than reading host paths back from a file a
|
||||
# rogue process could have rewritten to bind-mount, say, / into the guest.
|
||||
storage="$HOME/.windows"
|
||||
shared="$HOME/Windows"
|
||||
|
||||
[[ -z $tz ]] && tz="UTC"
|
||||
if ! write_compose "$ram" "$cores" "$disk" "$username" "$password" "$tz" "$storage" "$shared"; then
|
||||
echo "Could not migrate the existing configuration automatically." >&2
|
||||
echo "Re-run: omarchy-windows-vm install" >&2
|
||||
return 1
|
||||
fi
|
||||
write_credentials "$username" "$password"
|
||||
rm -f "$LEGACY_COMPOSE_FILE"
|
||||
}
|
||||
|
||||
# --- prerequisites -----------------------------------------------------------
|
||||
|
||||
check_prerequisites() {
|
||||
local DISK_SIZE_GB=${1:-64}
|
||||
local REQUIRED_SPACE=$((DISK_SIZE_GB + 10)) # Add 10GB for Windows ISO and overhead
|
||||
local REQUIRED_SPACE=$((DISK_SIZE_GB + 10)) # Add 10GB for Windows ISO and overhead
|
||||
|
||||
# Check for KVM support
|
||||
if [[ ! -e /dev/kvm ]]; then
|
||||
@@ -26,7 +386,7 @@ check_prerequisites() {
|
||||
|
||||
# Check disk space
|
||||
AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}')
|
||||
if (( AVAILABLE_SPACE < REQUIRED_SPACE )); then
|
||||
if ((AVAILABLE_SPACE < REQUIRED_SPACE)); then
|
||||
echo "❌ Insufficient disk space!"
|
||||
echo " Available: ${AVAILABLE_SPACE}GB"
|
||||
echo " Required: ${REQUIRED_SPACE}GB (${DISK_SIZE_GB}GB disk + 10GB for Windows image)"
|
||||
@@ -34,6 +394,8 @@ check_prerequisites() {
|
||||
fi
|
||||
}
|
||||
|
||||
# --- commands ----------------------------------------------------------------
|
||||
|
||||
install_windows() {
|
||||
# Set up trap to handle Ctrl+C
|
||||
trap "echo ''; echo 'Installation cancelled by user'; exit 1" INT
|
||||
@@ -43,10 +405,9 @@ install_windows() {
|
||||
omarchy-pkg-add freerdp openbsd-netcat gum
|
||||
|
||||
mkdir -p "$HOME/.windows"
|
||||
mkdir -p "$HOME/.config/windows"
|
||||
mkdir -p "$HOME/.local/share/applications"
|
||||
|
||||
cat << EOF | tee "$HOME/.local/share/applications/windows-vm.desktop" > /dev/null
|
||||
cat <<EOF | tee "$HOME/.local/share/applications/windows-vm.desktop" >/dev/null
|
||||
[Desktop Entry]
|
||||
Name=Windows
|
||||
Comment=Start Windows VM via Docker and connect with RDP
|
||||
@@ -70,7 +431,7 @@ EOF
|
||||
|
||||
RAM_OPTIONS=""
|
||||
for size in 2 4 8 16 32 64; do
|
||||
if (( size <= TOTAL_RAM_GB )); then
|
||||
if ((size <= TOTAL_RAM_GB)); then
|
||||
RAM_OPTIONS="$RAM_OPTIONS ${size}G"
|
||||
fi
|
||||
done
|
||||
@@ -91,16 +452,16 @@ EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ $SELECTED_CORES =~ ^[0-9]+$ ]] || (( SELECTED_CORES < 1 )) || (( SELECTED_CORES > TOTAL_CORES )); then
|
||||
if ! valid_cores "$SELECTED_CORES" || ((SELECTED_CORES > TOTAL_CORES)); then
|
||||
echo "Invalid input. Using default: 2 cores"
|
||||
SELECTED_CORES=2
|
||||
fi
|
||||
|
||||
AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}')
|
||||
MAX_DISK_GB=$((AVAILABLE_SPACE - 10)) # Leave 10GB for Windows image
|
||||
MAX_DISK_GB=$((AVAILABLE_SPACE - 10)) # Leave 10GB for Windows image
|
||||
|
||||
# Check if we have enough space for minimum
|
||||
if (( MAX_DISK_GB < 32 )); then
|
||||
if ((MAX_DISK_GB < 32)); then
|
||||
echo "❌ Insufficient disk space for Windows VM!"
|
||||
echo " Available: ${AVAILABLE_SPACE}GB"
|
||||
echo " Minimum required: 42GB (32GB disk + 10GB for Windows image)"
|
||||
@@ -109,7 +470,7 @@ EOF
|
||||
|
||||
DISK_OPTIONS=""
|
||||
for size in 32 64 128 256 512; do
|
||||
if (( size <= MAX_DISK_GB )); then
|
||||
if ((size <= MAX_DISK_GB)); then
|
||||
DISK_OPTIONS="$DISK_OPTIONS ${size}G"
|
||||
fi
|
||||
done
|
||||
@@ -139,6 +500,10 @@ EOF
|
||||
if [[ -z $USERNAME ]]; then
|
||||
USERNAME="docker"
|
||||
fi
|
||||
if ! valid_username "$USERNAME"; then
|
||||
echo "Invalid username (use letters, digits, - or _, up to 20 chars). Using default: docker"
|
||||
USERNAME="docker"
|
||||
fi
|
||||
|
||||
PASSWORD=$(gum input --placeholder="Password (Press enter to use default: admin)" --password --header="Enter Windows password:")
|
||||
if [[ -z $PASSWORD ]]; then
|
||||
@@ -147,6 +512,11 @@ EOF
|
||||
else
|
||||
PASSWORD_DISPLAY="(user-defined)"
|
||||
fi
|
||||
if ! valid_password "$PASSWORD"; then
|
||||
echo "Invalid password (printable characters, up to 64). Using default: admin"
|
||||
PASSWORD="admin"
|
||||
PASSWORD_DISPLAY="(default)"
|
||||
fi
|
||||
|
||||
# Display configuration summary
|
||||
gum style \
|
||||
@@ -170,38 +540,19 @@ EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p $HOME/Windows
|
||||
mkdir -p "$HOME/Windows"
|
||||
|
||||
# Create docker-compose.yml in user config directory
|
||||
cat << EOF | tee "$COMPOSE_FILE" > /dev/null
|
||||
services:
|
||||
windows:
|
||||
image: dockurr/windows
|
||||
container_name: omarchy-windows
|
||||
environment:
|
||||
VERSION: "11"
|
||||
RAM_SIZE: "$SELECTED_RAM"
|
||||
CPU_CORES: "$SELECTED_CORES"
|
||||
DISK_SIZE: "$SELECTED_DISK"
|
||||
USERNAME: "$USERNAME"
|
||||
PASSWORD: "$PASSWORD"
|
||||
TZ: "$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC)"
|
||||
ARGUMENTS: "-rtc base=localtime,clock=host,driftfix=slew"
|
||||
devices:
|
||||
- /dev/kvm
|
||||
- /dev/net/tun
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
ports:
|
||||
- 127.0.0.1:8006:8006
|
||||
- 127.0.0.1:3389:3389/tcp
|
||||
- 127.0.0.1:3389:3389/udp
|
||||
volumes:
|
||||
- $HOME/.windows:/storage
|
||||
- $HOME/Windows:/shared
|
||||
restart: "no"
|
||||
stop_grace_period: 2m
|
||||
EOF
|
||||
local tz
|
||||
tz=$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC)
|
||||
|
||||
# Write the root-owned compose from the validated settings (one prompt if
|
||||
# sudoless Docker is off), then bring the stack up.
|
||||
write_compose "$SELECTED_RAM" "$SELECTED_CORES" "$SELECTED_DISK" \
|
||||
"$USERNAME" "$PASSWORD" "$tz" "$HOME/.windows" "$HOME/Windows" || {
|
||||
echo "❌ Failed to write the Windows VM configuration."
|
||||
exit 1
|
||||
}
|
||||
write_credentials "$USERNAME" "$PASSWORD"
|
||||
|
||||
echo ""
|
||||
echo "Starting Windows VM installation..."
|
||||
@@ -210,14 +561,12 @@ EOF
|
||||
echo "Monitor installation progress at: http://127.0.0.1:8006"
|
||||
echo ""
|
||||
|
||||
# Start docker-compose with user's config
|
||||
echo "Starting Windows VM with docker-compose..."
|
||||
if ! docker-compose -f "$COMPOSE_FILE" up -d 2>&1; then
|
||||
if ! priv up; then
|
||||
echo "❌ Failed to start Windows VM!"
|
||||
echo " Common issues:"
|
||||
echo " - Docker daemon not running: sudo systemctl start docker"
|
||||
echo " - Port already in use: check if another VM is running"
|
||||
echo " - Permission issues: make sure you're in the docker group"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -237,7 +586,6 @@ EOF
|
||||
echo "Once finished, launch 'Windows' via Super + Space"
|
||||
echo ""
|
||||
echo "To stop the VM: omarchy-windows-vm stop"
|
||||
echo "To change resources: ~/.config/windows/docker-compose.yml"
|
||||
echo ""
|
||||
}
|
||||
|
||||
@@ -249,11 +597,12 @@ remove_windows() {
|
||||
|
||||
echo "Removing Windows VM..."
|
||||
|
||||
docker-compose -f "$COMPOSE_FILE" down 2>/dev/null || true
|
||||
migrate_legacy_compose 2>/dev/null || true
|
||||
if [[ -f $COMPOSE_FILE ]]; then
|
||||
priv remove || true
|
||||
fi
|
||||
|
||||
docker rmi dockurr/windows 2>/dev/null || echo "Image already removed or not found"
|
||||
|
||||
rm "$HOME/.local/share/applications/windows-vm.desktop"
|
||||
rm -f "$HOME/.local/share/applications/windows-vm.desktop"
|
||||
rm -rf "$HOME/.config/windows"
|
||||
rm -rf "$HOME/.windows"
|
||||
|
||||
@@ -267,49 +616,30 @@ launch_windows() {
|
||||
KEEP_ALIVE=true
|
||||
fi
|
||||
|
||||
# Check if config exists
|
||||
if [[ ! -f $COMPOSE_FILE ]]; then
|
||||
echo "Windows VM not configured. Please run: omarchy-windows-vm install"
|
||||
exit 1
|
||||
if ! migrate_legacy_compose; then
|
||||
if [[ ! -f $COMPOSE_FILE ]]; then
|
||||
echo "Windows VM not configured. Please run: omarchy-windows-vm install"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Extract credentials from compose file
|
||||
WIN_USER=$(grep "USERNAME:" "$COMPOSE_FILE" | sed 's/.*USERNAME: "\(.*\)"/\1/')
|
||||
WIN_PASS=$(grep "PASSWORD:" "$COMPOSE_FILE" | sed 's/.*PASSWORD: "\(.*\)"/\1/')
|
||||
|
||||
# Use defaults if not found
|
||||
# RDP credentials come from the private per-user file. Fall back to the compose
|
||||
# only when it is readable (sudoless mode), reversing the writer's escaping.
|
||||
WIN_USER=$(read_credential USERNAME) || WIN_USER=""
|
||||
WIN_PASS=$(read_credential PASSWORD) || WIN_PASS=""
|
||||
if [[ -z $WIN_USER || -z $WIN_PASS ]] && [[ -r $COMPOSE_FILE ]]; then
|
||||
[[ -z $WIN_USER ]] && WIN_USER=$(unescape "$(read_compose_value USERNAME "$COMPOSE_FILE")")
|
||||
[[ -z $WIN_PASS ]] && WIN_PASS=$(unescape "$(read_compose_value PASSWORD "$COMPOSE_FILE")")
|
||||
fi
|
||||
[[ -z $WIN_USER ]] && WIN_USER="docker"
|
||||
[[ -z $WIN_PASS ]] && WIN_PASS="admin"
|
||||
|
||||
# Check if container is already running
|
||||
CONTAINER_STATUS=$(docker inspect --format='{{.State.Status}}' omarchy-windows 2>/dev/null)
|
||||
|
||||
if [[ $CONTAINER_STATUS != "running" ]]; then
|
||||
echo "Starting Windows VM..."
|
||||
|
||||
# Send desktop notification
|
||||
omarchy-notification-send -g "Starting Windows VM" "This can take 15-30 seconds" -t 15000
|
||||
|
||||
if ! docker-compose -f "$COMPOSE_FILE" up -d 2>&1; then
|
||||
echo "❌ Failed to start Windows VM!"
|
||||
echo " Try checking: omarchy-windows-vm status"
|
||||
echo " View logs: docker logs omarchy-windows"
|
||||
omarchy-notification-send -u critical "Windows VM" "Failed to start Windows VM"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Waiting for Windows VM to start..."
|
||||
WAIT_COUNT=0
|
||||
until docker logs omarchy-windows 2>&1 | grep -qi "windows started successfully"; do
|
||||
sleep 2
|
||||
WAIT_COUNT=$((WAIT_COUNT + 1))
|
||||
if (( WAIT_COUNT > 60 )); then # 2 minutes timeout
|
||||
echo ""
|
||||
echo "❌ Timeout: Windows VM failed to start within 2 minutes"
|
||||
echo " Check logs: docker logs omarchy-windows"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "Starting Windows VM (this may prompt for authorization)..."
|
||||
if ! priv up_wait; then
|
||||
echo "❌ Failed to start Windows VM!"
|
||||
echo " Try checking: omarchy-windows-vm status"
|
||||
omarchy-notification-send -u critical "Windows VM" "Failed to start Windows VM"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Build the connection info
|
||||
@@ -329,14 +659,27 @@ To stop: omarchy-windows-vm stop"
|
||||
"" \
|
||||
"$LIFECYCLE"
|
||||
|
||||
# FreeRDP 3 tries Kerberos before NTLM for NLA, and krb5 ships /etc/krb5.conf
|
||||
# as the MIT sample with default_realm = ATHENA.MIT.EDU. Every connect then
|
||||
# goes looking for MIT's KDC: with internet up it fails fast, but off the
|
||||
# network each attempt blocks ~23s and no RDP window is ever drawn. The VM
|
||||
# authenticates against a local Windows account, so point FreeRDP at a
|
||||
# realm-less config and let it fall straight through to NTLM.
|
||||
KRB5_CONF="$HOME/.config/windows/krb5.conf"
|
||||
mkdir -p "$(dirname "$KRB5_CONF")"
|
||||
if [[ ! -f $KRB5_CONF ]]; then
|
||||
printf '[libdefaults]\n dns_lookup_kdc = false\n dns_lookup_realm = false\n' >"$KRB5_CONF"
|
||||
fi
|
||||
export KRB5_CONFIG="$KRB5_CONF"
|
||||
|
||||
# Detect display scale from Hyprland
|
||||
HYPR_SCALE=$(hyprctl monitors -j | jq -r '.[] | select (.focused == true) | .scale')
|
||||
SCALE_PERCENT=$(echo "$HYPR_SCALE" | awk '{print int($1 * 100)}')
|
||||
|
||||
RDP_SCALE=""
|
||||
if (( SCALE_PERCENT >= 170 )); then
|
||||
if ((SCALE_PERCENT >= 170)); then
|
||||
RDP_SCALE="/scale:180"
|
||||
elif (( SCALE_PERCENT >= 130 )); then
|
||||
elif ((SCALE_PERCENT >= 130)); then
|
||||
RDP_SCALE="/scale:140"
|
||||
fi
|
||||
# If scale is less than 130%, don't set any scale (use default 100)
|
||||
@@ -348,8 +691,12 @@ To stop: omarchy-windows-vm stop"
|
||||
if [[ $KEEP_ALIVE = "false" ]]; then
|
||||
echo ""
|
||||
echo "RDP session closed. Stopping Windows VM..."
|
||||
docker-compose -f "$COMPOSE_FILE" down
|
||||
echo "Windows VM stopped."
|
||||
if priv down; then
|
||||
echo "Windows VM stopped."
|
||||
else
|
||||
echo "⚠️ Could not stop the Windows VM (authorization declined?)."
|
||||
echo " It may still be running. Stop it with: omarchy-windows-vm stop"
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "RDP session closed. Windows VM is still running."
|
||||
@@ -358,24 +705,34 @@ To stop: omarchy-windows-vm stop"
|
||||
}
|
||||
|
||||
stop_windows() {
|
||||
migrate_legacy_compose 2>/dev/null || true
|
||||
if [[ ! -f $COMPOSE_FILE ]]; then
|
||||
echo "Windows VM not configured."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Stopping Windows VM..."
|
||||
docker-compose -f "$COMPOSE_FILE" down
|
||||
echo "Windows VM stopped."
|
||||
if priv down; then
|
||||
echo "Windows VM stopped."
|
||||
else
|
||||
echo "⚠️ Could not stop the Windows VM (authorization declined?). It may still be running."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
status_windows() {
|
||||
migrate_legacy_compose 2>/dev/null || true
|
||||
if [[ ! -f $COMPOSE_FILE ]]; then
|
||||
echo "Windows VM not configured."
|
||||
echo "To set up: omarchy-windows-vm install"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CONTAINER_STATUS=$(docker inspect --format='{{.State.Status}}' omarchy-windows 2>/dev/null)
|
||||
if ! CONTAINER_STATUS=$(priv status); then
|
||||
echo "Could not query the Windows VM (authorization declined?)."
|
||||
echo "To try again: omarchy-windows-vm status"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z $CONTAINER_STATUS ]]; then
|
||||
echo "Windows VM container not found."
|
||||
@@ -421,28 +778,41 @@ show_usage() {
|
||||
|
||||
# Main command dispatcher
|
||||
case "$1" in
|
||||
install)
|
||||
install_windows
|
||||
;;
|
||||
remove)
|
||||
remove_windows
|
||||
;;
|
||||
launch|start)
|
||||
launch_windows "$2"
|
||||
;;
|
||||
stop|down)
|
||||
stop_windows
|
||||
;;
|
||||
status)
|
||||
status_windows
|
||||
;;
|
||||
help|--help|-h|"")
|
||||
show_usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown command: $1" >&2
|
||||
echo "" >&2
|
||||
show_usage >&2
|
||||
__priv)
|
||||
((EUID == 0)) || {
|
||||
echo "omarchy-windows-vm __priv must run as root" >&2
|
||||
exit 1
|
||||
;;
|
||||
}
|
||||
action="$2"
|
||||
shift 2
|
||||
valid_priv_action "$action" || {
|
||||
echo "omarchy-windows-vm: unknown privileged action" >&2
|
||||
exit 1
|
||||
}
|
||||
"__priv_${action}" "$@"
|
||||
;;
|
||||
install)
|
||||
install_windows
|
||||
;;
|
||||
remove)
|
||||
remove_windows
|
||||
;;
|
||||
launch | start)
|
||||
launch_windows "$2"
|
||||
;;
|
||||
stop | down)
|
||||
stop_windows
|
||||
;;
|
||||
status)
|
||||
status_windows
|
||||
;;
|
||||
help | --help | -h | "")
|
||||
show_usage
|
||||
;;
|
||||
*)
|
||||
echo "Unknown command: $1" >&2
|
||||
echo "" >&2
|
||||
show_usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -4,4 +4,4 @@
|
||||
# To put it into use, remove .sample from this file name.
|
||||
|
||||
# Example: Show the name of the font that was just set.
|
||||
# notify-send -u low "New font" "Your new font is $1"
|
||||
# omarchy-notification-send -u low "New font" "Your new font is $1"
|
||||
@@ -6,5 +6,5 @@
|
||||
weather=$(omarchy-weather-status 2>/dev/null) || true
|
||||
|
||||
if [[ -n $weather && $weather != "Weather unavailable" ]]; then
|
||||
notify-send -u low "$weather"
|
||||
omarchy-notification-send -u low "$weather"
|
||||
fi
|
||||
@@ -4,4 +4,4 @@
|
||||
# To put it into use, remove .sample from this file name.
|
||||
|
||||
# Example: Show notification after the system has been updated.
|
||||
# notify-send -u low "Update Performed" "Your system is now up to date"
|
||||
# omarchy-notification-send -u low "Update Performed" "Your system is now up to date"
|
||||
@@ -4,4 +4,4 @@
|
||||
# To put it into use, remove .sample from this file name.
|
||||
|
||||
# Example: Show the name of the theme that was just set.
|
||||
# notify-send -u low "New theme" "Your new theme is $1"
|
||||
# omarchy-notification-send -u low "New theme" "Your new theme is $1"
|
||||
@@ -22,11 +22,34 @@ omarchy theme install <url> # Install from git repo
|
||||
Additional user backgrounds for any theme (stock or custom) go in
|
||||
`~/.config/omarchy/backgrounds/<theme-slug>/`.
|
||||
|
||||
## What a Theme Installed From a Repo May Not Contain
|
||||
|
||||
A theme the user wrote by hand in `~/.config/omarchy/themes` is unrestricted, as
|
||||
are Omarchy's own themes. From a theme cloned by `omarchy theme install`, Omarchy
|
||||
drops only what runs code: any `*.lua` (Hyprland requires a theme's
|
||||
`hyprland.lua` and `gum_env.lua` at login, Neovim loads `neovim.lua` at startup),
|
||||
the terminal configs `alacritty.toml`, `foot.ini`, `ghostty.conf` and
|
||||
`kitty.conf` (each names the program the terminal launches), and `vscode.json`
|
||||
(names a VS Code extension to install). Those are regenerated from `colors.toml`
|
||||
through `$OMARCHY_PATH/default/themed/*.tpl`, and named on stderr.
|
||||
|
||||
Everything else a cloned theme ships is kept, including `btop.theme`,
|
||||
`chromium.theme`, `helix.toml`, `icons.theme`, `keyboard.rgb` and `shell.toml`.
|
||||
Omarchy tells a cloned theme from the user's own by the `.git` directory a clone
|
||||
leaves behind.
|
||||
|
||||
To change how Omarchy themes an app for every theme, write the template rather
|
||||
than the theme: `~/.config/omarchy/themed/<config-name>.tpl` overrides the
|
||||
built-in one. See `docs/theming.md` in the Omarchy repo.
|
||||
|
||||
## Customizing a Stock Theme
|
||||
|
||||
Never edit stock themes under `/usr/share/omarchy/themes/` — changes are lost
|
||||
on update. Two safe options:
|
||||
|
||||
Both write into `~/.config/omarchy/themes`, where a theme the user wrote is
|
||||
unrestricted — the list above applies only to a theme cloned from a repo.
|
||||
|
||||
**Overlay (preferred for small tweaks):** create a user theme directory with
|
||||
the SAME slug containing only the files you want to change. When the theme is
|
||||
applied, the stock theme is copied first and your files win on top:
|
||||
|
||||
@@ -45,8 +45,8 @@ alias ....='cd ../../..'
|
||||
# Tools
|
||||
alias a='omarchy-agent --inline'
|
||||
alias c='opencode --auto'
|
||||
alias cx='printf "\033[2J\033[3J\033[H" && claude --permission-mode bypassPermissions'
|
||||
alias cy='codex -s danger-full-access -a never'
|
||||
alias cx='printf "\033[2J\033[3J\033[H" && claude --permission-mode auto'
|
||||
alias cy='codex --approve-for-me'
|
||||
alias d='docker'
|
||||
alias r='rails'
|
||||
alias t='tmux attach || tmux new -s Work'
|
||||
|
||||
@@ -13,7 +13,7 @@ if o.preinstalled_bindings_enabled() then
|
||||
o.bind("SUPER + CTRL + RETURN", "Herdr", { omarchy = "terminal-herdr" })
|
||||
o.bind("SUPER + SHIFT + M", "Music", { omarchy = "spotify" })
|
||||
o.bind("SUPER + SHIFT + ALT + M", "Music TUI", { tui = "cliamp", focus = true })
|
||||
o.bind("SUPER + SHIFT + D", "Docker", { tui = "lazydocker" })
|
||||
o.bind("SUPER + SHIFT + D", "Docker", { tui = "omarchy-launch-docker-tui" })
|
||||
o.bind("SUPER + SHIFT + G", "Signal", { omarchy = "signal" })
|
||||
o.bind("SUPER + SHIFT + O", "Obsidian", { launch = "obsidian", focus = "^obsidian$" })
|
||||
o.bind("SUPER + SHIFT + W", "Omawrite", { launch = "omawrite" })
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Disable a Hyprland input device whose name was stored as data, not Lua.
|
||||
-- Device names come from USB descriptors and must never be loaded as code.
|
||||
|
||||
local paths = require("default.hypr.paths")
|
||||
|
||||
return function(kind)
|
||||
-- Hardcoded to ~/.local/state to match omarchy-toggle-input-device and the
|
||||
-- sibling bash toggle tools, which all write there regardless of
|
||||
-- XDG_STATE_HOME.
|
||||
local file = io.open(paths.home .. "/.local/state/omarchy/toggles/hypr/" .. kind .. "-disabled-name", "r")
|
||||
if not file then
|
||||
return
|
||||
end
|
||||
|
||||
local name = file:read("*l")
|
||||
file:close()
|
||||
|
||||
if name and name ~= "" then
|
||||
hl.device({ name = name, enabled = false })
|
||||
end
|
||||
end
|
||||
@@ -18,9 +18,20 @@ local function file_exists(path)
|
||||
return false
|
||||
end
|
||||
|
||||
-- Hyprland reaps its own children, so os.execute() can't retrieve an exit status
|
||||
-- from inside the compositor. Read a marker off stdout instead.
|
||||
function o.shell_succeeds(command)
|
||||
local ok, _, code = os.execute(command .. " >/dev/null 2>&1")
|
||||
return ok == true or ok == 0 or code == 0
|
||||
-- Subshell, so the redirection covers every command rather than binding to
|
||||
-- the last one and letting an earlier one write its own OK into the pipe.
|
||||
local pipe = io.popen("( " .. command .. " ) >/dev/null 2>&1 && echo OK")
|
||||
if not pipe then
|
||||
return false
|
||||
end
|
||||
|
||||
local output = pipe:read("*a") or ""
|
||||
pipe:close()
|
||||
|
||||
return output:find("OK", 1, true) ~= nil
|
||||
end
|
||||
|
||||
function o.cmd_present(command)
|
||||
@@ -125,7 +136,7 @@ function o.bind_toggle(keys, description, toggle, options)
|
||||
end
|
||||
|
||||
function o.notify(message)
|
||||
return "notify-send -u low " .. shell_quote(message)
|
||||
return "omarchy-notification-send -u low " .. shell_quote(message)
|
||||
end
|
||||
|
||||
function o.window(match, rules)
|
||||
|
||||
+13
-3
@@ -4,9 +4,19 @@
|
||||
|
||||
local home = os.getenv("HOME")
|
||||
|
||||
-- A variable that is set but empty means "unset" (XDG Base Directory spec);
|
||||
-- bash's ${VAR:-fallback} in the sibling tools treats it the same way.
|
||||
local function env_or(name, fallback)
|
||||
local value = os.getenv(name)
|
||||
if value == nil or value == "" then
|
||||
return fallback
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
return {
|
||||
home = home,
|
||||
config_home = os.getenv("XDG_CONFIG_HOME") or (home .. "/.config"),
|
||||
state_home = os.getenv("XDG_STATE_HOME") or (home .. "/.local/state"),
|
||||
omarchy_path = os.getenv("OMARCHY_PATH") or "/usr/share/omarchy",
|
||||
config_home = env_or("XDG_CONFIG_HOME", home .. "/.config"),
|
||||
state_home = env_or("XDG_STATE_HOME", home .. "/.local/state"),
|
||||
omarchy_path = env_or("OMARCHY_PATH", "/usr/share/omarchy"),
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
-- Pass a module prefix for normal package.path modules, e.g.
|
||||
-- require_all.files(paths.omarchy_path .. "/default/hypr/apps", "default.hypr.apps")
|
||||
-- Pass nil as the prefix when the directory itself has been added to package.path.
|
||||
-- Pass options.exclude as a set of base names (without ".lua") to skip; a legacy
|
||||
-- file that must never be loaded as code stays on disk for a migration to remove.
|
||||
|
||||
local M = {}
|
||||
|
||||
@@ -12,19 +14,23 @@ local function shell_quote(path)
|
||||
end
|
||||
|
||||
function M.files(dir, module_prefix, options)
|
||||
local exclude = options and options.exclude or {}
|
||||
local handle = io.popen("find " .. shell_quote(dir) .. " -maxdepth 1 -type f -name '*.lua' -printf '%f\\n' 2>/dev/null | sort")
|
||||
if handle then
|
||||
for filename in handle:lines() do
|
||||
local module = filename:gsub("%.lua$", "")
|
||||
if module_prefix then
|
||||
module = module_prefix .. "." .. module
|
||||
end
|
||||
local name = filename:gsub("%.lua$", "")
|
||||
if not exclude[name] then
|
||||
local module = name
|
||||
if module_prefix then
|
||||
module = module_prefix .. "." .. module
|
||||
end
|
||||
|
||||
if options and options.reload then
|
||||
package.loaded[module] = nil
|
||||
end
|
||||
if options and options.reload then
|
||||
package.loaded[module] = nil
|
||||
end
|
||||
|
||||
require(module)
|
||||
require(module)
|
||||
end
|
||||
end
|
||||
handle:close()
|
||||
end
|
||||
|
||||
@@ -4,6 +4,20 @@ local require_all = require("default.hypr.require_all")
|
||||
local toggles_dir = paths.state_home .. "/omarchy/toggles/hypr"
|
||||
package.path = toggles_dir .. "/?.lua;" .. package.path
|
||||
|
||||
require_all.files(toggles_dir, nil, { reload = true })
|
||||
-- touchpad-disabled.lua / touchscreen-disabled.lua were generated Lua in older
|
||||
-- versions and could carry an injected USB device name. They must never be loaded
|
||||
-- as code again: exclude them so a not-yet-migrated install cannot execute a
|
||||
-- leftover payload on reload. The migration recovers the name and deletes them.
|
||||
require_all.files(toggles_dir, nil, {
|
||||
reload = true,
|
||||
exclude = {
|
||||
["touchpad-disabled"] = true,
|
||||
["touchscreen-disabled"] = true,
|
||||
},
|
||||
})
|
||||
|
||||
local disabled_input_device = require("default.hypr.disabled-input-device")
|
||||
disabled_input_device("touchpad")
|
||||
disabled_input_device("touchscreen")
|
||||
|
||||
require("default.hypr.workspace-layouts")
|
||||
@@ -173,6 +173,7 @@
|
||||
"setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"},
|
||||
"setup.security.sshd": {"icon":"","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"},
|
||||
"setup.security.passwordless-sudo": {"icon":"","label":"Passwordless Sudo","action":"omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless"},
|
||||
"setup.security.sudoless-docker": {"icon":"","label":"Sudoless Docker","when":"omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"},
|
||||
"setup.config.hyprland": {"icon":"","label":"Hyprland","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/hyprland.lua\""},
|
||||
"setup.config.hyprsunset": {"icon":"","label":"Hyprsunset","action":"omarchy-launch-config-editor ~/.config/hypr/hyprsunset.conf && omarchy-restart-hyprsunset"},
|
||||
"setup.config.xcompose": {"icon":"","label":"XCompose","action":"omarchy-launch-config-editor ~/.XCompose && omarchy-restart-xcompose"},
|
||||
@@ -283,6 +284,7 @@
|
||||
"remove.security.fingerprint": {"icon":"","label":"Fingerprint","when":"omarchy-pkg-present fprintd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fingerprint"},
|
||||
"remove.security.fido2": {"icon":"","label":"Fido2","when":"omarchy-pkg-present pam-u2f","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-fido2"},
|
||||
"remove.security.sshd": {"icon":"","label":"SSHD","when":"systemctl is-enabled --quiet sshd","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sshd"},
|
||||
"remove.security.sudoless-docker": {"icon":"","label":"Sudoless Docker","when":"! omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-remove-security-sudoless-docker"},
|
||||
"remove.browser.chrome": {"icon":"","label":"Chrome","when":"omarchy-pkg-present google-chrome","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser chrome'"},
|
||||
"remove.browser.edge": {"icon":"","label":"Edge","when":"omarchy-pkg-present microsoft-edge-stable-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser edge'"},
|
||||
"remove.browser.brave": {"icon":"","label":"Brave","when":"omarchy-pkg-present brave-bin","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-remove-browser brave'"},
|
||||
@@ -326,7 +328,7 @@
|
||||
"update.omarchy": {"icon":"","iconFont":"omarchy","label":"Omarchy","action":"omarchy-launch-floating-terminal-with-presentation omarchy-update"},
|
||||
"update.channel": {"icon":"","label":"Channel"},
|
||||
"update.config": {"icon":"","label":"Config","title":"Reset to default"},
|
||||
"update.themes": {"icon":"","label":"Extra Themes","action":"omarchy-launch-floating-terminal-with-presentation omarchy-theme-update"},
|
||||
"update.themes": {"icon":"","label":"Extra Themes","when":"omarchy-theme-extras","action":"omarchy-launch-floating-terminal-with-presentation omarchy-theme-update"},
|
||||
"update.process": {"icon":"","label":"Process","title":"Restart"},
|
||||
"update.hardware": {"icon":"","label":"Hardware","title":"Restart"},
|
||||
"update.firmware": {"icon":"","label":"Firmware","action":"omarchy-launch-floating-terminal-with-presentation omarchy-update-firmware"},
|
||||
|
||||
+39
-1
@@ -6,13 +6,22 @@ Omarchy themes live under `themes/<name>/` in the source tree (installed at
|
||||
`colors.toml`; Omarchy generates the active theme files from
|
||||
`default/themed/*.tpl` when `omarchy-theme-set <name>` runs.
|
||||
|
||||
Beyond `colors.toml` and hand-written config overrides, a first-party theme can
|
||||
ship `backgrounds/` (users overlay their own via
|
||||
`~/.config/omarchy/backgrounds/<name>/`; the active image is the
|
||||
`~/.local/state/omarchy/current/background` symlink), `preview.png` and
|
||||
`preview-unlock.png` for the theme switcher, `icons.theme`, `keyboard.rgb`,
|
||||
`unlock.png`, and a `light.mode` marker file.
|
||||
|
||||
A theme installed from a git repo is held to a much shorter list; see [What an installed theme may not ship](#what-an-installed-theme-may-not-ship).
|
||||
|
||||
## Theme activation flow
|
||||
|
||||
`omarchy-theme-set <name>` builds a clean staging directory at
|
||||
`~/.local/state/omarchy/current/next-theme`:
|
||||
|
||||
1. Copy the first-party theme from `themes/<name>/`.
|
||||
2. Overlay any user theme files from `~/.config/omarchy/themes/<name>/`.
|
||||
2. Overlay `~/.config/omarchy/themes/<name>/`, in full when the user wrote it and filtered when it came from a git repo, naming anything it dropped on stderr.
|
||||
3. If needed, generate `colors.toml` from `alacritty.toml`.
|
||||
4. Run `omarchy-theme-set-templates` to render templates into the staging
|
||||
theme.
|
||||
@@ -28,6 +37,34 @@ User templates in `~/.config/omarchy/themed/*.tpl` are processed before the
|
||||
built-in templates. If a user template has the same output filename as a
|
||||
built-in template, the built-in output is skipped.
|
||||
|
||||
After activation, `omarchy-theme-set` fires the `theme-set` hook
|
||||
(`~/.config/omarchy/hooks/theme-set*`, theme name in `$1`) and dispatches a
|
||||
parallel retint of running apps — terminals, Hyprland, btop, browser, editors,
|
||||
and the rest of the `post_theme_commands` list in `bin/omarchy-theme-set`.
|
||||
Making a new app follow theme changes means adding its restart/retint command
|
||||
to that list. Runs serialize on a `flock`, so scripted theme changes queue
|
||||
instead of racing.
|
||||
|
||||
## What an installed theme may not ship
|
||||
|
||||
`themes/<name>/` in this repo is Omarchy's own code and is trusted. So is a theme the user wrote by hand in `~/.config/omarchy/themes/<name>/`: it is their machine and their file, and both stage in full.
|
||||
|
||||
`omarchy theme install <url>` is different. It clones a stranger's git repo straight into that same directory, so the contents are whatever the theme author pushed. `omarchy-theme-set` tells the two apart the way `omarchy-theme-extras` already does — a `.git` directory means it was cloned, while a plain directory or a symlink to a working copy is the user's own — and from a cloned one it drops only what can run code:
|
||||
|
||||
- any `*.lua` — Hyprland `require`s a theme's `hyprland.lua` and `gum_env.lua` at login, and Neovim loads its `neovim.lua` at startup
|
||||
- `alacritty.toml`, `foot.ini`, `ghostty.conf`, `kitty.conf` — each names the program the terminal launches
|
||||
- `vscode.json` — names the extension `omarchy-theme-set-vscode` installs, and a VS Code extension is arbitrary JavaScript
|
||||
|
||||
Symlinks are dropped with them, at any depth; in a cloned theme they point wherever the theme author chose. Everything a cloned theme ships that is colour is kept, including files Omarchy would otherwise have generated — `btop.theme`, `chromium.theme`, `helix.toml`, `shell.toml`, `icons.theme`, `keyboard.rgb` and the rest — so a theme can still say exactly how it wants each app to look. What is dropped gets generated from `default/themed/*.tpl` instead, and is named on stderr.
|
||||
|
||||
A denylist is only right while it is maintained. Adding a template for another terminal, or for another editor that loads Lua, means adding it to `INSTALLED_THEME_DENIED` in `bin/omarchy-theme-set`; `test/shell.d/theme-staging-test.sh` fails on any `default/themed/*.tpl` whose output is recorded as neither code nor colour, so a new template cannot be added without that decision being made.
|
||||
|
||||
A theme predating `colors.toml` is not left without a palette: its `alacritty.toml` is read through `omarchy-theme-colors-from-alacritty` into a scratch directory and only the resulting `colors.toml` is staged, so the colors survive and the terminal config does not.
|
||||
|
||||
The restriction lives in `omarchy-theme-set` rather than in `omarchy-theme-install` on purpose. Filtering at staging also covers themes installed before the rule existed and files a theme gains later through `omarchy theme update`.
|
||||
|
||||
What this does not cover: a theme distributed as an archive rather than a git repo, extracted into `~/.config/omarchy/themes/` by hand, is indistinguishable from one the user wrote and stages in full. `omarchy theme install` only takes git URLs, so the supported path is always filtered, but the check is a statement about where a theme came from and not a sandbox.
|
||||
|
||||
## `colors.toml`
|
||||
|
||||
`colors.toml` provides the palette keys used by templates. Keys are grouped
|
||||
@@ -335,6 +372,7 @@ local active_border_color = { colors = { "rgba(33ccffee)", "rgba(00ff99ee)" }, a
|
||||
## Adding or overriding theme files
|
||||
|
||||
- Add palette values to `themes/<name>/colors.toml`.
|
||||
- Hand-written overrides work everywhere except a `.lua`, a terminal config or a `vscode.json` in a theme cloned from a git repo; see [What an installed theme may not ship](#what-an-installed-theme-may-not-ship).
|
||||
- Prefer generated files when the theme can be expressed with templates.
|
||||
- Add a hand-written file in `themes/<name>/` only when that theme needs to
|
||||
override the generated output entirely.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Switching between the stock providers is a one-click toggle in the network
|
||||
# panel, so it must not stop for a password. Custom is deliberately absent: it
|
||||
# points the machine at servers the caller supplies, and it already runs in a
|
||||
# terminal that can ask.
|
||||
%wheel ALL=(root) NOPASSWD: /usr/bin/omarchy-dns Cloudflare, /usr/bin/omarchy-dns Google, /usr/bin/omarchy-dns DHCP
|
||||
+16
-10
@@ -1,10 +1,16 @@
|
||||
# Record the docker group for provisioning first-boot user creation and factory reset,
|
||||
# then grant it directly when the install user already exists (deferred-provisioning
|
||||
# installs create the user at first boot instead).
|
||||
provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}"
|
||||
mkdir -p "$provisioning_dir"
|
||||
grep -qxF docker "$provisioning_dir/groups" 2>/dev/null || echo docker >>"$provisioning_dir/groups"
|
||||
|
||||
if [[ -n ${OMARCHY_INSTALL_USER:-} ]] && getent passwd "$OMARCHY_INSTALL_USER" >/dev/null; then
|
||||
usermod -aG docker "$OMARCHY_INSTALL_USER"
|
||||
fi
|
||||
# The Docker daemon runs as root and its socket is root-owned, so membership in
|
||||
# the docker group is equivalent to passwordless root: any process in it can
|
||||
# `docker run -v /:/host` and rewrite the host as root. We therefore do NOT add
|
||||
# the install user to the docker group by default, so a single rogue process
|
||||
# running as the user cannot silently escalate to root.
|
||||
#
|
||||
# The daemon is still enabled (docker.socket, in enable-services.sh) for system
|
||||
# use. The Docker TUI (Super + Shift + D) and the Windows VM reach it through a
|
||||
# polkit prompt, and the plain `docker` CLI runs under sudo. Users who want the
|
||||
# convenience back can opt in, behind a warning, with:
|
||||
#
|
||||
# omarchy-setup-security-sudoless-docker (Setup > Security > Sudoless Docker)
|
||||
#
|
||||
# Nothing to do here now that the group is no longer granted, but the file stays
|
||||
# as the recorded home of this decision and a hook for future daemon config.
|
||||
:
|
||||
@@ -1,6 +1,25 @@
|
||||
# Enable Synaptics InterTouch for confirmed touchpads if not already loaded
|
||||
|
||||
if grep -qi synaptics /proc/bus/input/devices \
|
||||
&& ! lsmod | grep -q '^psmouse'; then
|
||||
modprobe psmouse synaptics_intertouch=1
|
||||
#
|
||||
# Only when modprobe can resolve psmouse for the running kernel, and never
|
||||
# fatally. Installs run this under arch-chroot, where uname -r still names the
|
||||
# live ISO's kernel while /lib/modules holds the target's -- the two differ on
|
||||
# every machine that is not a T2 Mac, so modprobe failed with "Module psmouse
|
||||
# not found in directory /lib/modules/<live kernel>" and took the whole install
|
||||
# down with it. Asking modprobe itself first (-qn resolves a module without
|
||||
# loading it) keeps that mismatch quiet, and the warning below covers every
|
||||
# other reason it declines. An optional touchpad improvement must not be able to
|
||||
# halt an install.
|
||||
#
|
||||
# Loading a module into the live kernel does nothing for the installed system
|
||||
# either way, so this only takes effect when it runs on the booted machine.
|
||||
# Persisting the switch instead would mean writing options psmouse
|
||||
# synaptics_intertouch=1 to /etc/modprobe.d, which forces the SMBus transport
|
||||
# past the kernel's own allowlist on every touchpad merely named "synaptics" in
|
||||
# /proc/bus/input/devices -- a wider change than this one, and not one to make
|
||||
# blind.
|
||||
if grep -qi synaptics "${OMARCHY_SYNAPTIC_INPUT_DEVICES:-/proc/bus/input/devices}" \
|
||||
&& ! lsmod | grep -q '^psmouse' \
|
||||
&& modprobe -qn psmouse; then
|
||||
modprobe psmouse synaptics_intertouch=1 ||
|
||||
echo "Warning: could not enable Synaptics InterTouch on psmouse" >&2
|
||||
fi
|
||||
@@ -78,7 +78,7 @@ lua51
|
||||
luarocks
|
||||
man-db
|
||||
mariadb-libs
|
||||
mise
|
||||
mise-bin
|
||||
moonlight-qt
|
||||
mpv
|
||||
mpv-mpris
|
||||
@@ -108,7 +108,8 @@ python-poetry-core
|
||||
ttfx
|
||||
qemu-user-static-binfmt
|
||||
qrencode
|
||||
quickshell-git
|
||||
qt6-imageformats
|
||||
quickshell
|
||||
ripgrep
|
||||
ruby
|
||||
tensaku
|
||||
|
||||
@@ -5,5 +5,5 @@ set -e
|
||||
if omarchy-done ensure voxtype-install-invitation; then
|
||||
omarchy-notification-send -u critical -g "Install Dictation with Voxtype" \
|
||||
"Click to install voice dictation for Omarchy." \
|
||||
--exec "omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install"
|
||||
--exec omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install
|
||||
fi
|
||||
@@ -7,5 +7,5 @@ set -e
|
||||
if [[ -z $(omarchy-default-agent) ]] && omarchy-done ensure agent-setup-invitation; then
|
||||
omarchy-notification-send -u critical -g "Set your default agent" \
|
||||
"Let your favorite agent help with Omarchy." \
|
||||
--exec "omarchy menu summon setup.default.agent"
|
||||
--exec omarchy menu summon setup.default.agent
|
||||
fi
|
||||
@@ -8,5 +8,5 @@ if omarchy-hw-fingerprint && [[ ! -f /etc/pam.d/omarchy-lock-fingerprint ]] &&
|
||||
omarchy-done ensure fingerprint-setup-invitation; then
|
||||
omarchy-notification-send -u critical -g "Setup Fingerprint Reader" \
|
||||
"Enable sudo and unlocking with your fingerprint." \
|
||||
--exec "omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint"
|
||||
--exec omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint
|
||||
fi
|
||||
@@ -1,11 +1,11 @@
|
||||
notify_update() {
|
||||
omarchy-notification-send -u critical -g "Update System" "Click to update the system." \
|
||||
--exec "omarchy-launch-floating-terminal-with-presentation omarchy-update"
|
||||
--exec omarchy-launch-floating-terminal-with-presentation omarchy-update
|
||||
}
|
||||
|
||||
notify_wifi() {
|
||||
omarchy-notification-send -u critical -g "Setup Wi-Fi" "Click to configure the wireless network." \
|
||||
--exec "omarchy-shell shell toggle omarchy.network"
|
||||
--exec omarchy-shell shell toggle omarchy.network
|
||||
}
|
||||
|
||||
announce_network() {
|
||||
|
||||
@@ -21,7 +21,7 @@ From the terminal, the same switches are `omarchy toggle <thing>`. Run `omarchy
|
||||
| Suspend | — | `omarchy toggle suspend` |
|
||||
| Hybrid GPU | — | `omarchy toggle hybrid gpu` |
|
||||
|
||||
The touchpad, touchscreen, and hybrid GPU switches live under _Trigger > Hardware_ (`Super + Ctrl + H`) rather than under Toggle, since they only show up when you actually have that hardware. The touchpad and touchscreen ones survive a Hyprland reload — the disabled state is written back out as a small Lua file that Hyprland sources on startup.
|
||||
The touchpad, touchscreen, and hybrid GPU switches live under _Trigger > Hardware_ (`Super + Ctrl + H`) rather than under Toggle, since they only show up when you actually have that hardware. The touchpad and touchscreen ones survive a Hyprland reload — the disabled device's name is saved to a small state file that Hyprland reads on startup to disable it again.
|
||||
|
||||
The Toggle menu also carries a few things that aren't `omarchy toggle` commands but behave the same: battery percentage in the bar, workspace layout (`Super + L`), window gaps (`Super + Shift + Backspace`), and the 1-window square aspect (`Super + Ctrl + Backspace`).
|
||||
|
||||
|
||||
@@ -18,9 +18,11 @@ To install, say, Ruby, you'd run `mise use -g ruby`, which will both install Rub
|
||||
|
||||
## Docker
|
||||
|
||||
[Docker](https://www.docker.com/) hardly needs any introduction. It allows you to run isolated containers, and Omarchy installs everything needed to run it well. This includes Docker itself, [Docker Compose](https://docs.docker.com/compose/), and the user group changes needed for you to run Docker as the normal user and not as root.
|
||||
[Docker](https://www.docker.com/) hardly needs any introduction. It allows you to run isolated containers, and Omarchy installs everything needed to run it well, including Docker itself and [Docker Compose](https://docs.docker.com/compose/).
|
||||
|
||||
Remember to checkout the Lazydocker command to manage your containers in a cool TUI using `Super + Shift + D`.
|
||||
By default your user is *not* in the `docker` group. That group is effectively passwordless root — anything in it can `docker run -v /:/host` and take over the machine — so a single rogue script or dependency running as you would otherwise be one command away from root. So on the command line you run Docker with `sudo` (`sudo docker ps`, `sudo docker compose up`), and the graphical tools that talk to the daemon — the Docker TUI on `Super + Shift + D` and the Windows VM — ask for authorization when they need it. If you want the convenience of a groupless setup back and understand the tradeoff, enable it from **Setup > Security > Sudoless Docker** (or run `omarchy-setup-security-sudoless-docker`), which adds you to the `docker` group after a warning; then plain `docker` and the `d` alias work without `sudo` again.
|
||||
|
||||
Remember to checkout the Lazydocker command to manage your containers in a cool TUI using `Super + Shift + D`; it asks for authorization the first time unless you have enabled sudoless Docker.
|
||||
|
||||
You can setup the common databases for local development in Docker using _Install > Development > Docker DB_ in the Omarchy menu.
|
||||
|
||||
|
||||
@@ -36,6 +36,6 @@ There's no GPU passthrough with this setup, so it's not suitable for gaming or v
|
||||
|
||||
The version installed is Windows 11 Pro, unactivated. You'll need your own license key to use the gated features.
|
||||
|
||||
You can change the resource allocation later by editing `~/.config/windows/docker-compose.yml`, which is also where you'd mount a USB device. See all the options on [the Dockur Windows project](https://github.com/dockur/windows).
|
||||
You can change the resource allocation later by re-running `omarchy-windows-vm install`, which rewrites the VM's configuration from your answers. The compose file itself now lives at `/var/lib/omarchy/windows/docker-compose.yml` and is owned by root — that is deliberate, so a process running as you cannot rewrite it and have the privileged bring-up mount your whole disk into the container. If you need to hand-edit it (for example to mount a USB device), edit it with `sudo` and see all the options on [the Dockur Windows project](https://github.com/dockur/windows).
|
||||
|
||||
To get rid of the whole thing, use _Remove > Windows_ from the Omarchy menu. That deletes the VM's disk and all its data, so make sure anything you care about is out of `~/Windows` first.
|
||||
@@ -36,7 +36,7 @@ SSH is off until you turn it on with _Setup > Security > SSHD_, which starts the
|
||||
|
||||
[Tailscale](https://tailscale.com/) is a mesh VPN that makes reaching all your computers and servers over the internet simple and secure. Install it with _Install > Service > Tailscale_.
|
||||
|
||||
That gives you a Tailscale panel in the bar, which connects and disconnects the tailnet, switches accounts, and picks an exit node — your own machines and Mullvad regions both show up in the list. It also browses your machines, and that's where Taildrop lives: select a machine and press `s` to send it files, or `c`, `n`, and `d` to copy its IP, name, or full DNS name. The terminal equivalent is `omarchy tailscale send <machine> [file...]`, and files sent to you land in `~/Downloads` automatically.
|
||||
That gives you a Tailscale panel in the bar, which connects and disconnects the tailnet, switches accounts, and picks an exit node — your own machines and Mullvad regions both show up in the list. It also browses your machines, and that's where Taildrop lives: select a machine and press `s` to send it files, or `c`, `n`, and `d` to copy its IP, name, or full DNS name. The terminal equivalent is `omarchy tailscale send <machine> [file...]`, and files sent to you land in `~/Downloads` automatically. The notification announcing an arrival waits until you click it open or dismiss it, so a file that turns up while you're away from the machine is still there to answer when you get back.
|
||||
|
||||
Installing it also adds a web app for the Tailscale admin console.
|
||||
|
||||
|
||||
@@ -6,6 +6,16 @@ The main file you have to tweak is `colors.toml`. That defines the color set tha
|
||||
|
||||
You can also use the included Aether application to create a new theme using a lovely GUI interface to play with colors and search for backgrounds. Just start it via the apps menu on `Super + Alt + Space`.
|
||||
|
||||
### What an installed theme can contain
|
||||
|
||||
A theme you write yourself in `~/.config/omarchy/themes` can contain whatever you like — it's your machine and your file, and Omarchy applies all of it.
|
||||
|
||||
A theme you install from someone else's repo with `omarchy theme install` keeps everything that's colour, and loses the handful of files that would run code on your machine: any `.lua` file, the terminal configs (`alacritty.toml`, `foot.ini`, `ghostty.conf`, `kitty.conf`), and `vscode.json`. A theme's `hyprland.lua` is Lua your compositor runs at login, a terminal config names the program your terminal starts, and `vscode.json` names a VSCode extension to install. Installing someone's theme should change what your desktop looks like, never what it runs.
|
||||
|
||||
Everything else still works exactly as the theme author wrote it — `btop.theme`, `chromium.theme`, `helix.toml`, `icons.theme`, `shell.toml`, the backgrounds and the previews are all kept. Only what was dropped gets regenerated from `colors.toml` on your machine.
|
||||
|
||||
Omarchy tells the two apart by whether the theme has its own git repo inside it, which is what `omarchy theme install` leaves behind when it clones. So a theme you wrote stays yours, and one you pulled off the internet stays colours.
|
||||
|
||||
### Light mode
|
||||
|
||||
If you're making a light mode theme, set `mode = "light"` at the top of your `colors.toml`. Then it'll automatically be paired with light mode for all the apps. (The old way of dropping an empty file called `light.mode` in the root of your theme still works too.)
|
||||
@@ -28,4 +38,6 @@ There's a fully commented `alacritty.toml.tpl.sample` in that folder to copy fro
|
||||
|
||||
If you want to distribute your theme so others can use it, you need to put it on a public git server, like GitHub. Then people can install it using _Install > Style > Theme_ in the Omarchy menu using that URL. It's recommended that you follow the naming convention of `omarchy-[themename]-theme`, as the theme will show correctly as just `[themename]` in the theme selection menu after installation.
|
||||
|
||||
Remember that once it's installed from a repo, any `.lua`, terminal config or `vscode.json` it ships is dropped, so don't build the theme around those.
|
||||
|
||||
You can have your theme added to [the extra themes page](https://omarchy.org/themes/) by sending a pull request to [the omarchy-site repo](https://github.com/omacom-io/omarchy-site).
|
||||
@@ -4,6 +4,6 @@ if [[ ! -f $HOME/.local/state/omarchy/preinstalls-removed ]]; then
|
||||
omarchy-mise-install github:can1357/oh-my-pi omp
|
||||
omarchy-mise-install npm:@xai-official/grok grok
|
||||
omarchy-mise-install crush
|
||||
elif [[ -f $HOME/.local/bin/omp ]] && grep -Fq 'mise use -g "oh-my-pi"' "$HOME/.local/bin/omp"; then
|
||||
elif [[ -f $HOME/.local/bin/omp ]] && grep -Eq 'mise use -g .*"oh-my-pi"' "$HOME/.local/bin/omp"; then
|
||||
rm -f "$HOME/.local/bin/omp"
|
||||
fi
|
||||
@@ -0,0 +1,18 @@
|
||||
echo "Remove the literal \\n[text-bindings] line an earlier migration wrote into foot.ini"
|
||||
|
||||
# A 3.8.3 migration appended the section header with `sed -i '$a\\\n[text-bindings]'`,
|
||||
# one backslash too many, so GNU sed wrote a backslash and an "n" as two ordinary
|
||||
# characters ahead of the section name, leaving the single line \n[text-bindings]
|
||||
# where a blank line and a [text-bindings] header belong. foot rejects it with
|
||||
# "syntax error: key/value pair has no value" and stops reading the rest of the file.
|
||||
#
|
||||
# The later text-binding migration looks for the header with `grep -qxF`, which the
|
||||
# literal line doesn't match, so it appends a second real [text-bindings] section and
|
||||
# leaves the broken line in place. Removing the line is what actually repairs the
|
||||
# config; the duplicate section that migration added is valid and harmless.
|
||||
|
||||
foot_config="$HOME/.config/foot/foot.ini"
|
||||
|
||||
if [[ -f $foot_config ]] && grep -qxF '\n[text-bindings]' "$foot_config"; then
|
||||
sed -i '/^\\n\[text-bindings\]$/d' "$foot_config"
|
||||
fi
|
||||
@@ -0,0 +1,17 @@
|
||||
echo "Switch mise to the mise-bin package from the Omarchy repo"
|
||||
|
||||
# mise-bin carries mise's own release artifacts -- PGO+BOLT-optimized on x86_64,
|
||||
# glibc-native on both arches -- and takes over from Arch's mise, which it both
|
||||
# provides and conflicts with.
|
||||
#
|
||||
# The swap has to happen in one transaction. Removing mise first breaks
|
||||
# omarchy-zsh and omarchy-fish, which depend on it; the provides is what keeps
|
||||
# them satisfied when mise-bin lands in the same transaction that drops mise.
|
||||
#
|
||||
# omarchy-pkg-add cannot do that swap: pacman answers its own conflict question
|
||||
# with No under --noconfirm and fails the whole transaction ("unresolvable
|
||||
# package conflicts detected"). --ask=4 is that one question, answered yes.
|
||||
|
||||
if omarchy-pkg-missing mise-bin; then
|
||||
sudo pacman -S --noconfirm --ask=4 mise-bin
|
||||
fi
|
||||
@@ -0,0 +1,3 @@
|
||||
echo "Add webp decoding to the shell"
|
||||
|
||||
omarchy-pkg-add qt6-imageformats
|
||||
@@ -0,0 +1,9 @@
|
||||
echo "Switch back to the packaged quickshell now that 0.3.1 kills synchronously"
|
||||
|
||||
# 0.3.1 fixes `kill` returning before the instance has exited, which is the only
|
||||
# reason Omarchy shipped the git build.
|
||||
if omarchy-pkg-present quickshell-git; then
|
||||
# One transaction with --ask 4 so pacman accepts replacing the conflicting
|
||||
# quickshell-git in place; packages depending on quickshell stay satisfied.
|
||||
sudo pacman -S --noconfirm --ask 4 quickshell
|
||||
fi
|
||||
@@ -0,0 +1,25 @@
|
||||
echo "Re-stage the current theme so an installed theme's code is dropped"
|
||||
|
||||
# A theme installed from a repo could ship hyprland.lua, gum_env.lua, neovim.lua
|
||||
# and terminal configs, and they were copied straight into the staged theme that
|
||||
# Hyprland requires at login and the terminals include at launch. Staging drops
|
||||
# them now, but an install that already applied such a theme keeps the staged
|
||||
# copies until something changes the theme, which may be never. Re-stage once so
|
||||
# the fix reaches the themes already in place rather than only the next one.
|
||||
theme_name_path="$HOME/.local/state/omarchy/current/theme.name"
|
||||
|
||||
[[ -s $theme_name_path ]] || exit 0
|
||||
|
||||
theme_name=$(<"$theme_name_path")
|
||||
|
||||
# A theme removed while it was still current leaves theme.name naming it and the
|
||||
# staged copy behind, so there is nothing left to re-stage from and those staged
|
||||
# files are exactly the ones this is here to drop. Seed the default instead,
|
||||
# which is where a fresh install starts and what the removal should have left.
|
||||
if [[ ! -d $OMARCHY_PATH/themes/$theme_name && ! -d $HOME/.config/omarchy/themes/$theme_name ]]; then
|
||||
echo "Theme '$theme_name' no longer exists; applying the default instead"
|
||||
omarchy-theme-set "Tokyo Night"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
omarchy-theme-refresh
|
||||
@@ -0,0 +1,116 @@
|
||||
echo "Take ownership of the FIDO2 authfile so it cannot be rewritten without root"
|
||||
|
||||
authfile="/etc/fido2/fido2"
|
||||
|
||||
# omarchy-migrate records this migration as complete whenever it exits zero, so
|
||||
# a line printed here scrolls past once in the update terminal and is never
|
||||
# shown again. The states below cannot be repaired without deciding what to do
|
||||
# with a file we do not own, and they are exactly the ones where the authfile
|
||||
# may already be under someone else's control, so say so where it outlives the
|
||||
# scrollback as well.
|
||||
report_unrepairable() {
|
||||
echo " $1"
|
||||
echo " $2"
|
||||
omarchy-notification-send -u critical -g "FIDO2 authfile needs attention" "$1 $2" || true
|
||||
}
|
||||
|
||||
# Nothing to repair on any machine that never set FIDO2 up, which is almost all
|
||||
# of them. Checked before any sudo so those machines never see a password
|
||||
# prompt. -L as well as -e: a dangling symlink is invisible to -e.
|
||||
if [[ ! -L $authfile && ! -e $authfile ]]; then
|
||||
# Absence and "cannot look" are the same answer to the tests above. The old
|
||||
# setup created /etc/fido2 with `sudo mkdir -p`, which took the union of the
|
||||
# caller's umask and sudoers' 0022, so anyone registering under `umask 077`
|
||||
# left it mode 0700 with the user-owned authfile still inside. Escalate for
|
||||
# that case alone -- a machine that never set FIDO2 up has no directory here
|
||||
# and still reaches exit 0 without a password prompt. Not through a symlink:
|
||||
# chmod would act on whatever it points at.
|
||||
authdir=${authfile%/*}
|
||||
|
||||
if [[ -L $authdir || ! -d $authdir || -x $authdir ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ask root whether a registration is behind it before touching the directory
|
||||
# itself. An aborted setup that left an empty 0700 directory, or one an
|
||||
# administrator deliberately keeps private, must not have its mode widened
|
||||
# and its group and special bits discarded for a repair it does not need.
|
||||
if ! sudo test -e "$authfile" && ! sudo test -L "$authfile"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sudo chmod 755 "$authdir"
|
||||
fi
|
||||
|
||||
# The old privileged move could install a symlink here if its fixed staging path
|
||||
# was redirected. Reported, not repaired: chown follows symlinks and would take
|
||||
# ownership of the target instead, and removing it would strip sudo and polkit
|
||||
# from anyone whose only credential is the token.
|
||||
if [[ -L $authfile ]]; then
|
||||
report_unrepairable "$authfile is a symlink, not a regular file." \
|
||||
"Leaving it alone. If you did not create it, remove it and re-run Setup > Security > Fido2."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# A directory or a device here is no more ours to rewrite than a symlink is,
|
||||
# and changing a directory's mode would alter an object we do not own.
|
||||
if [[ ! -f $authfile ]]; then
|
||||
report_unrepairable "$authfile is not a regular file." \
|
||||
"Leaving it alone. Remove it and re-run Setup > Security > Fido2."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Migration state is per-user, so every account re-runs this. The file's own
|
||||
# ownership is the state check: the second account finds the repair already
|
||||
# done and exits without escalating.
|
||||
owner=$(stat -c %U "$authfile" 2>/dev/null) || owner=""
|
||||
group=$(stat -c %G "$authfile" 2>/dev/null) || group=""
|
||||
mode=$(stat -c %a "$authfile" 2>/dev/null) || mode=""
|
||||
if [[ $owner == "root" && $group == "root" && $mode == "644" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Setup used to `mv` this in from /tmp, which carried the invoking user's
|
||||
# ownership into /etc. Root ownership stops that user from rewriting their own
|
||||
# PAM credential without root. Mode 644 keeps the public credential mapping
|
||||
# readable when pam_u2f opens an absolute authfile as the authenticating user.
|
||||
#
|
||||
# Rename a fresh copy over the path rather than chowning in place. A descriptor
|
||||
# opened while the file was still the user's own stays writable on that inode
|
||||
# through any later chmod or chown, since permission is checked at open(2), and
|
||||
# pam_u2f resolving the path would keep landing on it. Replacing the inode
|
||||
# leaves that descriptor writing to a file nothing reads.
|
||||
stage=""
|
||||
|
||||
safe_stage_path() {
|
||||
local candidate=$1
|
||||
local prefix="$authfile.new."
|
||||
local suffix
|
||||
|
||||
[[ $candidate == "$prefix"* ]] || return 1
|
||||
suffix=${candidate#"$prefix"}
|
||||
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
|
||||
}
|
||||
|
||||
cleanup_stage() {
|
||||
local status=$?
|
||||
|
||||
if safe_stage_path "$stage"; then
|
||||
sudo rm -f -- "$stage" || true
|
||||
fi
|
||||
|
||||
return "$status"
|
||||
}
|
||||
|
||||
trap cleanup_stage EXIT
|
||||
stage=$(sudo mktemp "$authfile.new.XXXXXX")
|
||||
|
||||
if ! safe_stage_path "$stage" || [[ ! -f $stage || -L $stage ]]; then
|
||||
echo " Could not create a safe staging file beside $authfile."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sudo install -T -m 644 -o root -g root "$authfile" "$stage"
|
||||
sudo mv -Tf "$stage" "$authfile"
|
||||
stage=""
|
||||
trap - EXIT
|
||||
@@ -0,0 +1,22 @@
|
||||
echo "Move this install to the opt-in docker group default (the group is root-equivalent)"
|
||||
|
||||
# The docker group grants passwordless root (a container can bind-mount / and
|
||||
# rewrite the host), so Omarchy no longer puts users in it by default. Bring
|
||||
# existing installs in line: remove this user from the group if present. The
|
||||
# change applies after a reboot, so it stays reachable until then. Anyone who
|
||||
# wants passwordless docker back can opt in, behind a warning, with
|
||||
# Setup > Security > Sudoless Docker. Reuses the removal command so there is one
|
||||
# source of truth for the privileged change and its notice; DEFER_REBOOT keeps
|
||||
# it from prompting mid-update — omarchy-update-restart handles the reboot once
|
||||
# the whole update has finished.
|
||||
if id -nG "$USER" | grep -qw docker; then
|
||||
OMARCHY_DEFER_REBOOT=1 omarchy-remove-security-sudoless-docker
|
||||
fi
|
||||
|
||||
# The Docker app entry copied into ~/.local/share/applications used to run
|
||||
# lazydocker directly; it now needs the wrapper that prompts for daemon access
|
||||
# (or runs directly under sudoless Docker). Refresh just that file.
|
||||
dest="$HOME/.local/share/applications/Docker.desktop"
|
||||
if [[ -f $dest ]]; then
|
||||
cp "$OMARCHY_PATH/applications/Docker.desktop" "$dest"
|
||||
fi
|
||||
@@ -0,0 +1,39 @@
|
||||
echo "Store Hyprland input-device names as data instead of generated Lua"
|
||||
|
||||
# omarchy-toggle-input-device used to interpolate hyprctl device names into
|
||||
# hyprctl eval and a generated Lua file. Those names come from USB descriptors,
|
||||
# so recover the plain device name as data and delete the generated Lua. A name
|
||||
# that could have broken out of the old Lua string literal is discarded, not
|
||||
# trusted. The old script wrote to ~/.local/state regardless of XDG_STATE_HOME.
|
||||
toggles_dir="$HOME/.local/state/omarchy/toggles/hypr"
|
||||
|
||||
reapply=0
|
||||
|
||||
for kind in touchpad touchscreen; do
|
||||
state_file="$toggles_dir/$kind-disabled.lua"
|
||||
name_file="$toggles_dir/$kind-disabled-name"
|
||||
|
||||
[[ -f $state_file ]] || continue
|
||||
|
||||
if [[ ! -f $name_file && -r $state_file ]]; then
|
||||
old=$(<"$state_file")
|
||||
pattern='^hl\.device\(\{ name = "([^"\\[:cntrl:]]+)", enabled = false \}\)$'
|
||||
if [[ $old =~ $pattern ]]; then
|
||||
printf '%s\n' "${BASH_REMATCH[1]}" >"$name_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
rm -f "$state_file"
|
||||
|
||||
if [[ -f $name_file ]]; then
|
||||
reapply=1
|
||||
fi
|
||||
done
|
||||
|
||||
# The package hook reloads Hyprland before migrations run, so this session has
|
||||
# already dropped the disable: the generated Lua is no longer loaded and the
|
||||
# name file did not exist yet to replace it. Reload once more now that it does,
|
||||
# or the device the user switched off stays on until their next login.
|
||||
if (( reapply )); then
|
||||
hyprctl reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
@@ -54,6 +54,15 @@ QtObject {
|
||||
Quickshell.execDetached(["bash", "-lc", command])
|
||||
}
|
||||
|
||||
// Run an argv vector without a shell interpreting it: the constant `exec "$@"`
|
||||
// means the args only ever land in positional parameters, which bash expands
|
||||
// without re-tokenizing — so untrusted data ($(id), a filename) stays literal.
|
||||
// The login shell (-l) keeps the PATH/session env GUI targets (tensaku, mpv,
|
||||
// xdg-open) need. Prefer this over execDetached for anything built from input.
|
||||
function execArgv(argv) {
|
||||
Quickshell.execDetached(["bash", "-lc", 'exec "$@"', "bash"].concat(argv))
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
}
|
||||
|
||||
@@ -369,7 +369,13 @@ PanelWindow {
|
||||
|
||||
Text {
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
text: dial.reading < 10 ? dial.reading.toFixed(1) : Math.round(dial.reading).toLocaleString(Qt.locale(), 'f', 0)
|
||||
// Both branches go through the locale: a reading is a measurement, so
|
||||
// its separators follow the system's number conventions rather than the
|
||||
// interface language. toFixed would have hardcoded a dot below 10 while
|
||||
// everything above it was already grouped for the locale.
|
||||
text: dial.reading < 10
|
||||
? dial.reading.toLocaleString(Qt.locale(), 'f', 1)
|
||||
: Math.round(dial.reading).toLocaleString(Qt.locale(), 'f', 0)
|
||||
color: root.onScrim
|
||||
font.family: root.fontFamily
|
||||
font.pixelSize: Style.font.display
|
||||
|
||||
@@ -1417,6 +1417,9 @@ Item {
|
||||
}
|
||||
|
||||
onPressAndHold: function(mouse) {
|
||||
// A widget above us propagates its composed press-and-hold down here without
|
||||
// ever handing over the grab, so we'd get no release or cancel to end the move.
|
||||
if (!gestureArea.pressed) return
|
||||
startDrag(mouse.x, mouse.y)
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,50 @@ emit_image() {
|
||||
}
|
||||
|
||||
emit_text() {
|
||||
jq -cRs 'select(length > 0) | {type:"text", text:.}'
|
||||
perl -MEncode=decode,FB_CROAK,LEAVE_SRC -MJSON::PP=encode_json -0777 -e '
|
||||
my $raw = <STDIN>;
|
||||
exit unless length $raw;
|
||||
|
||||
my $encoding;
|
||||
my $heuristic_encoding = 0;
|
||||
if ($raw =~ /^(?:\xFF\xFE|\xFE\xFF)/) {
|
||||
$encoding = "UTF-16";
|
||||
} elsif (length($raw) % 2 == 0 && index($raw, "\0") >= 0) {
|
||||
my $units = length($raw) / 2;
|
||||
my $nuls = $raw =~ tr/\0/\0/;
|
||||
|
||||
# Neither byte lane can reach the padding threshold when the entire
|
||||
# payload contains fewer NULs than that, so avoid two full string passes.
|
||||
if ($nuls * 4 >= $units * 3) {
|
||||
my $even_bytes = $raw;
|
||||
$even_bytes =~ s/(.)./$1/sg;
|
||||
my $even_nuls = $even_bytes =~ tr/\0/\0/;
|
||||
undef $even_bytes;
|
||||
|
||||
my $odd_bytes = $raw;
|
||||
$odd_bytes =~ s/.(.)/$1/sg;
|
||||
my $odd_nuls = $odd_bytes =~ tr/\0/\0/;
|
||||
|
||||
# BOM-less UTF-16 is indistinguishable from NUL-separated bytes. Decode
|
||||
# only when at least three quarters of the code units have consistent
|
||||
# padding and fewer than one quarter have NULs in the opposite byte.
|
||||
if ($odd_nuls * 4 >= $units * 3 && $even_nuls * 4 < $units) {
|
||||
$encoding = "UTF-16LE";
|
||||
$heuristic_encoding = 1;
|
||||
} elsif ($even_nuls * 4 >= $units * 3 && $odd_nuls * 4 < $units) {
|
||||
$encoding = "UTF-16BE";
|
||||
$heuristic_encoding = 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
my $text = $encoding ? eval { decode($encoding, $raw, FB_CROAK | LEAVE_SRC) } : undef;
|
||||
if ($heuristic_encoding && defined($text) && $text =~ /[\x00-\x08\x0E-\x1A\x1C-\x1F]/) {
|
||||
$text = undef;
|
||||
}
|
||||
$text = decode("UTF-8", $raw) unless defined $text;
|
||||
print "{\"type\":\"text\",\"text\":", encode_json($text), "}\n";
|
||||
'
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
|
||||
@@ -57,14 +57,39 @@ function glyphFromHints(hints) {
|
||||
return stringHint(hints, "omarchy-glyph")
|
||||
}
|
||||
|
||||
// Shell command to run when the card is clicked, sent by
|
||||
// omarchy-notification-send --exec. Carrying the action as data means it
|
||||
// travels with the popup through the persistence files, so a toast restored
|
||||
// after a shell restart clicks through exactly like a live one. A libnotify
|
||||
// action can't: its sender is still waiting on an id from a server generation
|
||||
// that no longer exists.
|
||||
function execFromHints(hints) {
|
||||
return stringHint(hints, "omarchy-exec")
|
||||
// The click action: a JSON argv string from omarchy-notification-send
|
||||
// --exec. Carried as data so a toast restored after a shell restart stays
|
||||
// clickable (a libnotify action can't — its sender is gone). Run via
|
||||
// Util.execArgv as bash positional parameters, never a shell string, so
|
||||
// attacker-controlled values (a title, a filename) can't become commands.
|
||||
function execArgvFromHints(hints) {
|
||||
return stringHint(hints, "omarchy-exec-argv")
|
||||
}
|
||||
|
||||
// Validate a persisted omarchy-exec-argv into a runnable argv, or null. This is
|
||||
// a STRUCTURAL check only: it fails closed on a malformed hint (non-array, a
|
||||
// non-string or empty program, or a leading-dash program that argv would read as
|
||||
// an option). It does not judge intent — a well-formed ["bash","-c",…] is
|
||||
// accepted. WHICH senders may set this hint is a separate boundary: any
|
||||
// session-bus process can, by the freedesktop protocol's design (see
|
||||
// docs/notifications.md), which is equivalent to same-uid code execution.
|
||||
function parseExecArgv(value) {
|
||||
var text = String(value || "")
|
||||
if (!text) return null
|
||||
|
||||
var parsed
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) return null
|
||||
for (var i = 0; i < parsed.length; i++) {
|
||||
if (typeof parsed[i] !== "string") return null
|
||||
}
|
||||
if (!parsed[0] || parsed[0].charAt(0) === "-") return null
|
||||
return parsed
|
||||
}
|
||||
|
||||
function shouldRenderCompactGlyph(glyph, iconSource, singleLineToast) {
|
||||
@@ -85,7 +110,7 @@ function snapshotOf(notification, timestamp) {
|
||||
body: n.body || "",
|
||||
image: n.image || "",
|
||||
glyph: glyphFromHints(n.hints),
|
||||
exec: execFromHints(n.hints),
|
||||
execArgv: execArgvFromHints(n.hints),
|
||||
urgency: n.urgency,
|
||||
expireTimeout: expireTimeout,
|
||||
timestamp: timestamp === undefined ? Date.now() : timestamp
|
||||
@@ -94,7 +119,7 @@ function snapshotOf(notification, timestamp) {
|
||||
|
||||
// Everything the popup card draws, and therefore everything an in-place
|
||||
// update has to write through to the row and its file.
|
||||
var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "exec", "urgency", "expireTimeout"]
|
||||
var POPUP_ROLES = ["app", "appIcon", "summary", "body", "image", "glyph", "execArgv", "urgency", "expireTimeout"]
|
||||
|
||||
function popupRoles() {
|
||||
return POPUP_ROLES
|
||||
@@ -136,7 +161,7 @@ function historyEntry(value, normalUrgency) {
|
||||
body: e.body || "",
|
||||
image: e.image || "",
|
||||
glyph: e.glyph || "",
|
||||
exec: e.exec || "",
|
||||
execArgv: e.execArgv || "",
|
||||
urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency,
|
||||
expireTimeout: 0,
|
||||
timestamp: e.timestamp || 0
|
||||
@@ -346,7 +371,8 @@ if (typeof module !== "undefined") {
|
||||
isEphemeralApp: isEphemeralApp,
|
||||
stringHint: stringHint,
|
||||
glyphFromHints: glyphFromHints,
|
||||
execFromHints: execFromHints,
|
||||
execArgvFromHints: execArgvFromHints,
|
||||
parseExecArgv: parseExecArgv,
|
||||
shouldRenderCompactGlyph: shouldRenderCompactGlyph,
|
||||
snapshotOf: snapshotOf,
|
||||
popupRoles: popupRoles,
|
||||
|
||||
@@ -353,18 +353,19 @@ Item {
|
||||
}
|
||||
|
||||
// Run the popup's click action, then dismiss. Omarchy's own toasts carry the
|
||||
// action as a command in the `exec` role (see execFromHints), which the
|
||||
// persistence files preserve, so restored toasts stay clickable. Third-party
|
||||
// clients register a libnotify action under the canonical identifier
|
||||
// "default" instead; that one only works while the sender is still live.
|
||||
// action as an argv vector in the `execArgv` role (see execArgvFromHints),
|
||||
// which the persistence files preserve, so restored toasts stay clickable.
|
||||
// Third-party clients register a libnotify action under the canonical
|
||||
// identifier "default" instead; that one only works while the sender is live.
|
||||
function invokePopupDefault(index) {
|
||||
if (index < 0 || index >= popupModel.count) return
|
||||
var entry = popupModel.get(index)
|
||||
var command = entry ? String(entry.exec || "") : ""
|
||||
if (command) {
|
||||
// Detached so the launched command outlives the shell process, which the
|
||||
// installer toasts depend on: they restart the shell as their first act.
|
||||
Util.execDetached(command)
|
||||
|
||||
// Run the argv (via Util.execArgv, no shell interpretation). Detached so it
|
||||
// outlives the shell, which installer toasts depend on: they restart it.
|
||||
var argv = NotificationLogic.parseExecArgv(entry ? entry.execArgv : "")
|
||||
if (argv) {
|
||||
Util.execArgv(argv)
|
||||
dismissPopup(index)
|
||||
return
|
||||
}
|
||||
@@ -665,7 +666,7 @@ Item {
|
||||
body: row.body,
|
||||
image: row.image,
|
||||
glyph: row.glyph || "",
|
||||
exec: row.exec || "",
|
||||
execArgv: row.execArgv || "",
|
||||
urgency: row.urgency,
|
||||
timestamp: row.timestamp
|
||||
}, imagesDir).entry)
|
||||
@@ -689,7 +690,7 @@ Item {
|
||||
body: "",
|
||||
image: "",
|
||||
glyph: "",
|
||||
exec: "",
|
||||
execArgv: "",
|
||||
urgency: NotificationUrgency.Low,
|
||||
expireTimeout: 0,
|
||||
timestamp: Date.now()
|
||||
|
||||
@@ -64,7 +64,11 @@ Panel {
|
||||
// convention. Clicking the grid's "W" heading writes the choice back to
|
||||
// shell.json.
|
||||
readonly property int weekStart: Model.normalizedWeekStart(setting("weekStartDay", null), Qt.locale().firstDayOfWeek)
|
||||
readonly property string nextWeekStartLabel: Qt.locale().dayName(Model.toggledWeekStart(weekStart), Locale.LongFormat)
|
||||
// The interface is English throughout, so day names are not taken from the
|
||||
// system locale. Where the week starts still is: that is a regional
|
||||
// convention rather than a translation, and it stays overridable above.
|
||||
readonly property var labelLocale: Qt.locale("en_US")
|
||||
readonly property string nextWeekStartLabel: labelLocale.dayName(Model.toggledWeekStart(weekStart), Locale.LongFormat)
|
||||
readonly property var weekdays: Model.weekdayOrder(weekStart)
|
||||
readonly property var weeks: Model.monthGrid(viewYear, viewMonth, weekStart, todayKey)
|
||||
|
||||
@@ -213,10 +217,9 @@ Panel {
|
||||
setWeekStart(Model.toggledWeekStart(root.weekStart))
|
||||
}
|
||||
|
||||
// Locale short day names, trimmed of the trailing period some locales
|
||||
// carry ("man." -> "MAN") so the header row stays a clean band of caps.
|
||||
// English short day names, matching the rest of the interface.
|
||||
function weekdayLabel(weekday) {
|
||||
return String(Qt.locale().dayName(weekday, Locale.ShortFormat)).replace(/\.$/, "").toUpperCase()
|
||||
return String(labelLocale.dayName(weekday, Locale.ShortFormat)).toUpperCase()
|
||||
}
|
||||
|
||||
SystemClock {
|
||||
|
||||
@@ -299,8 +299,17 @@ function wifiSectionTitle(wifiNetworks, index) {
|
||||
return ""
|
||||
}
|
||||
|
||||
function isProtected(security, openSecurity) {
|
||||
return security !== openSecurity
|
||||
// OWE (Enhanced Open) encrypts traffic without authenticating the user, so it
|
||||
// has no credentials to collect. The panel's lock is a credentials-required
|
||||
// affordance, so OWE should neither show it nor open its attached prompt.
|
||||
function requiresCredentials(security, openSecurity, oweSecurity) {
|
||||
// Only explicit passwordless types bypass the prompt. Unknown security
|
||||
// stays credentialed as the conservative fallback.
|
||||
return security !== openSecurity && security !== oweSecurity
|
||||
}
|
||||
|
||||
function canForgetNetwork(network) {
|
||||
return !!(network && network.known && !network.connected)
|
||||
}
|
||||
|
||||
// The password arrives on stdin and reaches nmcli through the scriptable
|
||||
@@ -316,10 +325,10 @@ var enterpriseConnectScript =
|
||||
" && nmcli connection up uuid \"$u\"" +
|
||||
" || { nmcli connection delete uuid \"$u\" >/dev/null 2>&1; false; }"
|
||||
|
||||
function networkFailureReason(reason, reasons) {
|
||||
function networkFailureReason(reason, needsCredentials, reasons) {
|
||||
var r = reasons || {}
|
||||
if (reason === r.NoSecrets) return "Passphrase required"
|
||||
if (reason === r.WifiAuthTimeout) return "Wrong password"
|
||||
if (needsCredentials && reason === r.NoSecrets) return "Passphrase required"
|
||||
if (needsCredentials && reason === r.WifiAuthTimeout) return "Wrong password"
|
||||
if (reason === r.WifiNetworkLost) return "Network lost"
|
||||
if (reason === r.WifiClientDisconnected) return "Disconnected"
|
||||
if (reason === r.WifiClientFailed) return "Connection failed"
|
||||
@@ -327,14 +336,15 @@ function networkFailureReason(reason, reasons) {
|
||||
}
|
||||
|
||||
// Whether a failed connect should reopen the passphrase prompt. NoSecrets
|
||||
// always means credentials are missing. An auth timeout on a protected
|
||||
// network means the saved passphrase is wrong (the same profile a first
|
||||
// failed attempt leaves behind as "known"), so the user needs a chance to
|
||||
// re-enter it -- connectWithPsk overwrites the stored PSK on submit.
|
||||
function shouldRepromptPassphrase(reason, isProtected, reasons) {
|
||||
// means credentials are missing only for a network that actually uses them.
|
||||
// An auth timeout on such a network means the saved passphrase is wrong (the
|
||||
// same profile a first failed attempt leaves behind as "known"), so the user
|
||||
// needs a chance to re-enter it -- connectWithPsk overwrites the stored PSK on
|
||||
// submit.
|
||||
function shouldRepromptPassphrase(reason, needsCredentials, reasons) {
|
||||
var r = reasons || {}
|
||||
if (reason === r.NoSecrets) return true
|
||||
return !!isProtected && reason === r.WifiAuthTimeout
|
||||
if (!needsCredentials) return false
|
||||
return reason === r.NoSecrets || reason === r.WifiAuthTimeout
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
@@ -361,7 +371,8 @@ if (typeof module !== "undefined") {
|
||||
wifiRow: wifiRow,
|
||||
sortWifiRows: sortWifiRows,
|
||||
wifiSectionTitle: wifiSectionTitle,
|
||||
isProtected: isProtected,
|
||||
requiresCredentials: requiresCredentials,
|
||||
canForgetNetwork: canForgetNetwork,
|
||||
enterpriseConnectScript: enterpriseConnectScript,
|
||||
networkFailureReason: networkFailureReason,
|
||||
shouldRepromptPassphrase: shouldRepromptPassphrase
|
||||
|
||||
@@ -398,7 +398,7 @@ Panel {
|
||||
}
|
||||
|
||||
function canForgetNetwork(net) {
|
||||
return !!(net && net.known && isProtected(net.security) && !net.connected)
|
||||
return Model.canForgetNetwork(net)
|
||||
}
|
||||
|
||||
function canShareNetwork(net) {
|
||||
@@ -417,8 +417,8 @@ Panel {
|
||||
}
|
||||
|
||||
// Enter/Space on the highlighted row. Mirrors row-click semantics:
|
||||
// connected → disconnect, protected-unknown → password prompt,
|
||||
// open/known → connect.
|
||||
// connected → disconnect, credentials-required/unknown → prompt,
|
||||
// passwordless/known → connect.
|
||||
function activateSelected() {
|
||||
if (busy || selectedIndex < 0 || selectedIndex >= wifiNetworks.length) return
|
||||
var net = wifiNetworks[selectedIndex]
|
||||
@@ -428,8 +428,8 @@ Panel {
|
||||
// connectedWifiNetwork when handed null, so a row left stale by scan churn
|
||||
// would otherwise tear down whatever is connected now instead.
|
||||
if (net.connected) { disconnectRow(net.ssid); return }
|
||||
if (isProtected(net.security) && !net.known) { openPasswordPrompt(net.ssid); return }
|
||||
connectKnown(net.ssid)
|
||||
if (requiresCredentials(net.security) && !net.known) { openPasswordPrompt(net.ssid); return }
|
||||
connectDirectly(net.ssid)
|
||||
}
|
||||
|
||||
// Bar pill state, derived from the native NetworkManager service so the
|
||||
@@ -680,8 +680,8 @@ Panel {
|
||||
root.close()
|
||||
}
|
||||
|
||||
function isProtected(security) {
|
||||
return Model.isProtected(security, WifiSecurityType.Open)
|
||||
function requiresCredentials(security) {
|
||||
return Model.requiresCredentials(security, WifiSecurityType.Open, WifiSecurityType.Owe)
|
||||
}
|
||||
|
||||
function openPasswordPrompt(ssid) {
|
||||
@@ -735,18 +735,18 @@ Panel {
|
||||
if (!network || actionKind === "" || actionSsid !== (network.name || "")) return
|
||||
actionTimeout.stop()
|
||||
failureSsid = actionSsid
|
||||
failureReason = networkFailureReason(reason)
|
||||
failureReason = networkFailureReason(reason, requiresCredentials(network.security))
|
||||
actionSsid = ""
|
||||
actionKind = ""
|
||||
refresh()
|
||||
}
|
||||
|
||||
function networkFailureReason(reason) {
|
||||
return Model.networkFailureReason(reason, connectionFailReasons)
|
||||
function networkFailureReason(reason, needsCredentials) {
|
||||
return Model.networkFailureReason(reason, needsCredentials, connectionFailReasons)
|
||||
}
|
||||
|
||||
function shouldRepromptPassphrase(reason, isProtected) {
|
||||
return Model.shouldRepromptPassphrase(reason, isProtected, connectionFailReasons)
|
||||
function shouldRepromptPassphrase(reason, needsCredentials) {
|
||||
return Model.shouldRepromptPassphrase(reason, needsCredentials, connectionFailReasons)
|
||||
}
|
||||
|
||||
function checkActionCompletion(network) {
|
||||
@@ -756,7 +756,7 @@ Panel {
|
||||
else if (actionKind === "forget" && !network.known && !network.stateChanging) clearNetworkAction()
|
||||
}
|
||||
|
||||
function connectKnown(ssid) {
|
||||
function connectDirectly(ssid) {
|
||||
runNetworkAction("connect", networkForSsid(ssid), function(network) { network.connect() })
|
||||
}
|
||||
|
||||
@@ -1587,8 +1587,8 @@ Panel {
|
||||
}
|
||||
|
||||
// A single Wi-Fi network entry. Collapses to a one-line pill normally;
|
||||
// expands inline to a passphrase prompt when the user picks a protected
|
||||
// network we don't have credentials for. Clicking a connected row
|
||||
// expands inline to a passphrase prompt when the user picks a network that
|
||||
// requires credentials we do not have. Clicking a connected row
|
||||
// disconnects.
|
||||
component NetworkRow: CursorSurface {
|
||||
id: row
|
||||
@@ -1597,14 +1597,14 @@ Panel {
|
||||
|
||||
readonly property bool isConnected: net && net.connected
|
||||
readonly property bool isKnown: !!(net && net.known)
|
||||
readonly property bool isProtected: net ? root.isProtected(net.security) : false
|
||||
readonly property bool requiresCredentials: net ? root.requiresCredentials(net.security) : false
|
||||
readonly property bool isEnterprise: net
|
||||
? (net.security === WifiSecurityType.Wpa2Eap || net.security === WifiSecurityType.WpaEap)
|
||||
: false
|
||||
readonly property bool canForgetFromLock: isKnown && isProtected && !isConnected
|
||||
readonly property bool canForget: root.canForgetNetwork(net)
|
||||
readonly property bool isSelected: root.focusSection === "wifi" && root.selectedIndex === index
|
||||
readonly property bool forgetFocused: isSelected && root.wifiActionFocused && canForgetFromLock
|
||||
readonly property bool forgetVisible: canForgetFromLock && (forgetFocused || rightMouse.containsMouse)
|
||||
readonly property bool forgetFocused: isSelected && root.wifiActionFocused && canForget
|
||||
readonly property bool forgetVisible: canForget && (!requiresCredentials || forgetFocused || rightMouse.containsMouse)
|
||||
|
||||
hasCursor: root.cursorActive && isSelected && !root.wifiActionFocused
|
||||
current: isConnected
|
||||
@@ -1631,7 +1631,7 @@ Panel {
|
||||
// failNetworkAction, which clears the action state.
|
||||
var ours = root.actionKind === "connect" && root.actionSsid === (row.net.ssid || "")
|
||||
root.failNetworkAction(root.networkForSsid(row.net.ssid), reason)
|
||||
if (ours && root.shouldRepromptPassphrase(reason, row.isProtected)) root.openPasswordPrompt(row.net.ssid)
|
||||
if (ours && root.shouldRepromptPassphrase(reason, row.requiresCredentials)) root.openPasswordPrompt(row.net.ssid)
|
||||
}
|
||||
function onConnectedChanged() {
|
||||
if (row.net) root.checkActionCompletion(root.networkForSsid(row.net.ssid))
|
||||
@@ -1692,11 +1692,11 @@ Panel {
|
||||
root.disconnectRow(row.net.ssid)
|
||||
return
|
||||
}
|
||||
if (row.isProtected && !row.isKnown) {
|
||||
if (row.requiresCredentials && !row.isKnown) {
|
||||
root.openPasswordPrompt(row.net.ssid)
|
||||
return
|
||||
}
|
||||
root.connectKnown(row.net.ssid)
|
||||
root.connectDirectly(row.net.ssid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1719,11 +1719,12 @@ Panel {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
}
|
||||
|
||||
// Shows a lock glyph for protected networks. Known disconnected
|
||||
// networks reveal the forget action when hovering that right edge.
|
||||
// The right edge shows a lock for networks that require credentials and
|
||||
// reveals Forget on hover. Known passwordless networks show Forget
|
||||
// directly rather than reserving an invisible or misleading target.
|
||||
Item {
|
||||
id: rightAction
|
||||
visible: row.isProtected
|
||||
visible: row.requiresCredentials || row.canForget
|
||||
width: Style.space(22)
|
||||
implicitHeight: lockIndicator.implicitHeight
|
||||
anchors.right: parent.right
|
||||
@@ -1731,6 +1732,7 @@ Panel {
|
||||
|
||||
Text {
|
||||
id: lockIndicator
|
||||
visible: row.requiresCredentials || row.forgetVisible
|
||||
width: parent.width
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
@@ -1754,7 +1756,7 @@ Panel {
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.LeftButton
|
||||
enabled: row.canForgetFromLock && !root.busy
|
||||
enabled: row.canForget && !root.busy
|
||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||
onContainsMouseChanged: if (containsMouse) { root.cursorActive = true; root.focusSection = "wifi"; root.selectedIndex = row.index; root.wifiActionFocused = true }
|
||||
onClicked: if (row.net) root.forget(row.net)
|
||||
|
||||
@@ -65,8 +65,20 @@ verify_services() {
|
||||
}
|
||||
|
||||
verify_runtime_tools() {
|
||||
timeout 20 docker info >/dev/null 2>&1 || fail "Docker is usable by the desktop user"
|
||||
pass "Docker is usable by the desktop user"
|
||||
# Docker access is intentionally NOT granted to the desktop user: the docker
|
||||
# group is root-equivalent, so a rogue process running as the user could
|
||||
# otherwise `docker run -v /:/host` its way to passwordless root. The daemon is
|
||||
# still enabled (docker.socket, checked in verify_services) and reached through
|
||||
# a polkit/sudo prompt; opting into sudoless Docker is a separate, warned step.
|
||||
command -v docker >/dev/null 2>&1 || fail "Docker CLI is installed"
|
||||
! id -nG | grep -qw docker || fail "desktop user must not be in the docker group"
|
||||
# The group name being absent is not sufficient — a world-writable socket or an
|
||||
# ACL would still hand the user the root daemon. Prove it is actually
|
||||
# unreachable without elevation.
|
||||
if timeout 10 docker info >/dev/null 2>&1; then
|
||||
fail "desktop user must not reach the Docker daemon without elevation"
|
||||
fi
|
||||
pass "Docker is installed but unreachable by the desktop user without elevation"
|
||||
|
||||
nvim --headless '+qa' >/dev/null 2>&1 || fail "Neovim starts headlessly"
|
||||
pass "Neovim starts headlessly"
|
||||
|
||||
@@ -18,10 +18,12 @@ mkdir -p "$(dirname "$hook_path")"
|
||||
cat >"$test_bin/omarchy-notification-send" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo notification >>"$TEST_LOG"
|
||||
exec_args=()
|
||||
while (($# > 0)); do
|
||||
[[ $1 == "--exec" ]] && echo "exec:$2" >>"$TEST_LOG"
|
||||
if [[ $1 == "--exec" ]]; then shift; exec_args=("$@"); break; fi
|
||||
shift
|
||||
done
|
||||
((${#exec_args[@]})) && echo "exec:${exec_args[*]}" >>"$TEST_LOG"
|
||||
EOF
|
||||
chmod +x "$test_bin/omarchy-notification-send"
|
||||
|
||||
|
||||
@@ -14,6 +14,17 @@ if rg -q 'barMoveSettling|barMoveSettleTimer' "$ROOT/shell/plugins/bar/Bar.qml";
|
||||
fi
|
||||
pass "bar move outline has no post-release settling state"
|
||||
|
||||
# A widget above the gesture area propagates its composed press-and-hold down
|
||||
# without handing over the grab, so the resulting move gets neither a release
|
||||
# nor a cancel and the ghost stays up for the session. Only the grabbing area
|
||||
# reports pressed, which is what separates the two, so the guard has to stay
|
||||
# ahead of the drag.
|
||||
if ! perl -0ne 'exit(/onPressAndHold:\s*function[^{]*\{[^}]*?\bpressed\b[^}]*?\bstartDrag\b/s ? 0 : 1)' \
|
||||
"$ROOT/shell/plugins/bar/Bar.qml"; then
|
||||
fail "bar move ignores a press-and-hold the gesture area does not hold the press for"
|
||||
fi
|
||||
pass "bar move ignores a press-and-hold propagated from a widget above"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const fs = require('fs')
|
||||
const bar = requireFromRoot('shell/plugins/bar/BarModel.js')
|
||||
|
||||
@@ -9,7 +9,6 @@ raw_command_checks=$(rg -l 'command -v' "$ROOT/bin" \
|
||||
[[ -z $raw_command_checks ]] || fail "bin commands use command helpers" "$raw_command_checks"
|
||||
pass "bin commands use command helpers"
|
||||
|
||||
raw_notifications=$(rg -l -P '^[[:space:]]*[^#[:space:]].*\bnotify-send\b' "$ROOT/bin" \
|
||||
| rg -v '/omarchy-notification-send$' || true)
|
||||
[[ -z $raw_notifications ]] || fail "bin commands use the notification helper" "$raw_notifications"
|
||||
raw_notifications=$(rg -l -P '^[[:space:]]*[^#[:space:]].*\bnotify-send\b' "$ROOT/bin" || true)
|
||||
[[ -z $raw_notifications ]] || fail "bin commands use the notification helper, never notify-send" "$raw_notifications"
|
||||
pass "bin commands use the notification helper"
|
||||
@@ -50,3 +50,202 @@ bash -c '
|
||||
' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "javascript:alert(1)" "$ROOT" &&
|
||||
fail "yt-dlp native host rejects non-web URLs"
|
||||
pass "yt-dlp native host rejects non-web URLs"
|
||||
|
||||
host_fn() {
|
||||
OMARCHY_PATH="$ROOT" OMARCHY_YTDLP_DIR="${download_dir:-$TMPDIR}" bash -c '
|
||||
source "$1"
|
||||
shift
|
||||
"$@"
|
||||
' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "$@"
|
||||
}
|
||||
|
||||
download_dir="$TMPDIR/videos"
|
||||
mkdir -p "$download_dir" "$TMPDIR/outside"
|
||||
good_file="$download_dir/clip [id].mp4"
|
||||
printf 'x' >"$good_file"
|
||||
printf 'x' >"$TMPDIR/outside/secret"
|
||||
ln -s "$TMPDIR/outside/secret" "$download_dir/escape.mp4"
|
||||
|
||||
resolved=$(host_fn resolve_download_file "$good_file")
|
||||
expected=$(realpath -e -- "$good_file")
|
||||
[[ $resolved == "$expected" ]] ||
|
||||
fail "yt-dlp native host accepts a regular file in the download dir" "$resolved"
|
||||
pass "yt-dlp native host accepts a regular file in the download dir"
|
||||
|
||||
host_fn resolve_download_file "--include=not-a-file" &&
|
||||
fail "yt-dlp native host rejects a forged mpv option as the download path"
|
||||
pass "yt-dlp native host rejects a forged mpv option as the download path"
|
||||
|
||||
host_fn resolve_download_file "$TMPDIR/outside/secret" &&
|
||||
fail "yt-dlp native host rejects a path outside the download dir"
|
||||
pass "yt-dlp native host rejects a path outside the download dir"
|
||||
|
||||
host_fn resolve_download_file "$download_dir/escape.mp4" &&
|
||||
fail "yt-dlp native host rejects a symlink that escapes the download dir"
|
||||
pass "yt-dlp native host rejects a symlink that escapes the download dir"
|
||||
|
||||
host_fn resolve_download_file $'clip.mp4\nOMARCHY_FILE\t--include=not-a-file' &&
|
||||
fail "yt-dlp native host rejects a path containing control characters"
|
||||
pass "yt-dlp native host rejects a path containing control characters"
|
||||
|
||||
newline_target="$download_dir/target"$'\n'
|
||||
printf 'x' >"$newline_target"
|
||||
# The decoy is the point: dropping the trailing newline lands on a real, different
|
||||
# file, so a resolver that strips it resolves to the wrong one instead of failing.
|
||||
printf 'x' >"$download_dir/target"
|
||||
ln -s "target"$'\n' "$download_dir/newline-link.mp4"
|
||||
|
||||
host_fn resolve_download_file "$download_dir/newline-link.mp4" &&
|
||||
fail "yt-dlp native host rejects a symlink whose target name ends in a newline"
|
||||
pass "yt-dlp native host rejects a symlink whose target name ends in a newline"
|
||||
|
||||
ln -s / "$TMPDIR/root-link"
|
||||
root_resolved=$(download_dir="$TMPDIR/root-link" host_fn resolve_download_file /etc/passwd)
|
||||
[[ $root_resolved == "/etc/passwd" ]] ||
|
||||
fail "yt-dlp native host accepts a file when the download dir resolves to /" "$root_resolved"
|
||||
pass "yt-dlp native host accepts a file when the download dir resolves to /"
|
||||
|
||||
title=$(host_fn title_from_file "$good_file")
|
||||
[[ $title == "clip [id]" ]] || fail "yt-dlp native host titles the toast from the filename" "$title"
|
||||
pass "yt-dlp native host titles the toast from the filename"
|
||||
|
||||
decoded_title=$(host_fn decode_title '"My Great Clip"')
|
||||
[[ $decoded_title == "My Great Clip" ]] ||
|
||||
fail "yt-dlp native host shows the page title on the toast" "$decoded_title"
|
||||
pass "yt-dlp native host shows the page title on the toast"
|
||||
|
||||
forged_title=$(host_fn decode_title '"Clip\nOMARCHY_FILE\tPlay me\t--include=not-a-file"')
|
||||
[[ $forged_title == "Clip" ]] ||
|
||||
fail "yt-dlp native host keeps only the readable part of a forged title" "$forged_title"
|
||||
pass "yt-dlp native host keeps only the readable part of a forged title"
|
||||
|
||||
host_fn decode_title '"--include=not-a-file"' &&
|
||||
fail "yt-dlp native host refuses a leading-dash title the notifier would reject as an option"
|
||||
pass "yt-dlp native host refuses a leading-dash title the notifier would reject as an option"
|
||||
|
||||
host_fn decode_title 'null' &&
|
||||
fail "yt-dlp native host refuses a title that is not a JSON string"
|
||||
pass "yt-dlp native host refuses a title that is not a JSON string"
|
||||
|
||||
dash_title=$(host_fn title_from_file "$download_dir/--include.mp4")
|
||||
[[ $dash_title == "Video" ]] || fail "yt-dlp native host does not pass a leading-dash title to the notifier" "$dash_title"
|
||||
pass "yt-dlp native host does not pass a leading-dash title to the notifier"
|
||||
|
||||
# The click action passes the path as a discrete --exec argument (asserted
|
||||
# end-to-end below against the real download); `--` keeps mpv from parsing a
|
||||
# leading-dash filename as an option.
|
||||
|
||||
parse_script="$TMPDIR/parse-ytdlp-lines.sh"
|
||||
cat >"$parse_script" <<'EOF'
|
||||
source "$1"
|
||||
filepath=""
|
||||
while IFS= read -r line; do
|
||||
case $line in
|
||||
OMARCHY_FILE*)
|
||||
resolved=$(resolve_download_file "${line#OMARCHY_FILE$'\t'}") || continue
|
||||
filepath=$resolved
|
||||
;;
|
||||
esac
|
||||
done
|
||||
printf '%s' "$filepath"
|
||||
EOF
|
||||
|
||||
# A title that ends in a newline closes its own record, so the forged record is the
|
||||
# last one and the real path lands on a line the loop ignores.
|
||||
poisoned=$(
|
||||
printf '%s\n' \
|
||||
$'OMARCHY_FILE\t'"$good_file" \
|
||||
$'OMARCHY_FILE\tPlay me\t--include=not-a-file' \
|
||||
$'\t'"$good_file" |
|
||||
OMARCHY_PATH="$ROOT" OMARCHY_YTDLP_DIR="$download_dir" bash "$parse_script" "$ROOT/bin/omarchy-chromium-ytdlp-host"
|
||||
)
|
||||
|
||||
[[ $poisoned == "$expected" ]] ||
|
||||
fail "yt-dlp native host keeps a real file after a forged OMARCHY_FILE record" "$poisoned"
|
||||
pass "yt-dlp native host keeps a real file after a forged OMARCHY_FILE record"
|
||||
|
||||
# Everything above tests the helpers in isolation. Drive the real download_url with
|
||||
# stubbed tools so the yt-dlp invocation and the toast's click command are covered
|
||||
# too: a record template that carried the title again would pass every test above.
|
||||
fake_root="$TMPDIR/fake"
|
||||
mkdir -p "$fake_root/bin"
|
||||
fake_dir="$TMPDIR/fake-videos"
|
||||
mkdir -p "$fake_dir"
|
||||
fake_file="$fake_dir/Real_Clip [id].mp4"
|
||||
printf 'x' >"$fake_file"
|
||||
ytdlp_argv="$TMPDIR/ytdlp-argv"
|
||||
notify_argv="$TMPDIR/notify-argv"
|
||||
|
||||
cat >"$fake_root/bin/yt-dlp" <<'EOF'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$YTDLP_ARGV_LOG"
|
||||
for arg in "$@"; do
|
||||
if [[ $arg == "--no-simulate" ]]; then
|
||||
printf 'OMARCHY_FILE\t%s\n' "$YTDLP_FAKE_FILE"
|
||||
[[ -n ${YTDLP_SKIP_TITLE:-} ]] || printf 'OMARCHY_TITLE\t%s\n' '"My Great Clip"'
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
EOF
|
||||
|
||||
cat >"$fake_root/bin/omarchy-notification-send" <<'EOF'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "$*" >>"$NOTIFY_ARGV_LOG"
|
||||
EOF
|
||||
|
||||
for stub in omarchy-osd omarchy-shell ffmpeg; do
|
||||
printf '#!/bin/bash\nexit 0\n' >"$fake_root/bin/$stub"
|
||||
done
|
||||
chmod +x "$fake_root/bin/"*
|
||||
|
||||
YTDLP_ARGV_LOG="$ytdlp_argv" NOTIFY_ARGV_LOG="$notify_argv" YTDLP_FAKE_FILE="$fake_file" \
|
||||
OMARCHY_PATH="$fake_root" OMARCHY_YTDLP_DIR="$fake_dir" \
|
||||
bash -c '
|
||||
source "$1"
|
||||
download_url "$2"
|
||||
' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "https://example.test/watch" >/dev/null 2>&1
|
||||
|
||||
(($(grep -c -- '--no-exec-before-download' "$ytdlp_argv") == 2)) ||
|
||||
fail "yt-dlp native host disarms configured exec hooks on both yt-dlp runs" "$(cat "$ytdlp_argv")"
|
||||
pass "yt-dlp native host disarms configured exec hooks on both yt-dlp runs"
|
||||
|
||||
# The stub answers with a title record whatever it is asked for, so assert the request
|
||||
# as well as the reply: without this the template could be dropped and nothing notice.
|
||||
grep -qF -- $'OMARCHY_TITLE\t%(title)j' "$ytdlp_argv" ||
|
||||
fail "yt-dlp native host asks for the title JSON-encoded" "$(cat "$ytdlp_argv")"
|
||||
pass "yt-dlp native host asks for the title JSON-encoded"
|
||||
|
||||
grep -qF -- "-o %(title)s.%(ext)s" "$ytdlp_argv" ||
|
||||
fail "yt-dlp native host names the saved file after the page title" "$(cat "$ytdlp_argv")"
|
||||
pass "yt-dlp native host names the saved file after the page title"
|
||||
|
||||
grep -q -- '--restrict-filenames' "$ytdlp_argv" &&
|
||||
fail "yt-dlp native host keeps spaces in the saved filename" "$(cat "$ytdlp_argv")"
|
||||
pass "yt-dlp native host keeps spaces in the saved filename"
|
||||
|
||||
grep -qF -- $'OMARCHY_FILE\t%(title)s' "$ytdlp_argv" &&
|
||||
fail "yt-dlp native host never prints the title into the file record" "$(cat "$ytdlp_argv")"
|
||||
pass "yt-dlp native host never prints the title into the file record"
|
||||
|
||||
grep -q -- "--exec mpv -- " "$notify_argv" ||
|
||||
fail "yt-dlp native host builds the click command as mpv -- <path>" "$(cat "$notify_argv")"
|
||||
pass "yt-dlp native host builds the click command as mpv -- <path>"
|
||||
|
||||
grep -qF -- "Download complete My Great Clip" "$notify_argv" ||
|
||||
fail "yt-dlp native host toasts the page title, not the sanitised filename" "$(cat "$notify_argv")"
|
||||
pass "yt-dlp native host toasts the page title, not the sanitised filename"
|
||||
|
||||
# A page that gives no usable title falls back to the filename rather than an empty toast.
|
||||
: >"$notify_argv"
|
||||
: >"$ytdlp_argv"
|
||||
YTDLP_ARGV_LOG="$ytdlp_argv" NOTIFY_ARGV_LOG="$notify_argv" YTDLP_FAKE_FILE="$fake_file" \
|
||||
YTDLP_SKIP_TITLE=1 OMARCHY_PATH="$fake_root" OMARCHY_YTDLP_DIR="$fake_dir" \
|
||||
bash -c '
|
||||
source "$1"
|
||||
download_url "$2"
|
||||
' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "https://example.test/watch" >/dev/null 2>&1
|
||||
|
||||
grep -qF -- "Download complete Real_Clip [id]" "$notify_argv" ||
|
||||
fail "yt-dlp native host falls back to the filename when no title record arrives" "$(cat "$notify_argv")"
|
||||
pass "yt-dlp native host falls back to the filename when no title record arrives"
|
||||
@@ -292,6 +292,93 @@ capture_output=$(printf 'closing app copy' | WL_PASTE_TEXT="stale read" XDG_RUNT
|
||||
[[ $capture_output == '{"type":"text","text":"closing app copy"}' ]] || fail "clipboard capture records watched text from stdin"
|
||||
pass "clipboard capture records watched text from stdin"
|
||||
|
||||
capture_output=$(printf '%s' 'UTF-16 clipboard - fixed' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
|
||||
[[ $capture_output == '{"type":"text","text":"UTF-16 clipboard - fixed"}' ]] || fail "clipboard capture decodes UTF-16LE text"
|
||||
pass "clipboard capture decodes UTF-16LE text"
|
||||
|
||||
capture_output=$(printf '%s' 'https://example.com/image — preview …' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
|
||||
[[ $capture_output == '{"type":"text","text":"https://example.com/image — preview …"}' ]] || fail "clipboard capture decodes mostly ASCII UTF-16LE text with Unicode punctuation"
|
||||
pass "clipboard capture decodes mostly ASCII UTF-16LE text with Unicode punctuation"
|
||||
|
||||
capture_output=$(printf '%s' 'Text with 日本 and 😀' | iconv -f UTF-8 -t UTF-16BE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
|
||||
[[ $capture_output == '{"type":"text","text":"Text with 日本 and 😀"}' ]] || fail "clipboard capture decodes mostly ASCII UTF-16BE text with Unicode characters"
|
||||
pass "clipboard capture decodes mostly ASCII UTF-16BE text with Unicode characters"
|
||||
|
||||
capture_output=$(printf '%s' 'ABC—' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
|
||||
[[ $capture_output == '{"type":"text","text":"ABC—"}' ]] || fail "clipboard capture decodes UTF-16LE text at the padding threshold"
|
||||
pass "clipboard capture decodes UTF-16LE text at the padding threshold"
|
||||
|
||||
printf '%s' 'ABCDEFGH————' | iconv -f UTF-8 -t UTF-16LE >"$TMPDIR/below-utf16-threshold"
|
||||
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/below-utf16-threshold")
|
||||
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/below-utf16-threshold")
|
||||
[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves UTF-16LE text below the padding threshold undecoded" "expected: $expected\nactual: $capture_output"
|
||||
pass "clipboard capture leaves UTF-16LE text below the padding threshold undecoded"
|
||||
|
||||
printf '%s' 'ABCĀ' | iconv -f UTF-8 -t UTF-16LE >"$TMPDIR/opposite-nul-threshold"
|
||||
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/opposite-nul-threshold")
|
||||
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/opposite-nul-threshold")
|
||||
[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves UTF-16LE text at the opposite-byte NUL threshold undecoded" "expected: $expected\nactual: $capture_output"
|
||||
pass "clipboard capture leaves UTF-16LE text at the opposite-byte NUL threshold undecoded"
|
||||
|
||||
capture_output=$(printf 'a\fb' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
|
||||
[[ $capture_output == '{"type":"text","text":"a\fb"}' ]] || fail "clipboard capture preserves UTF-16LE form feeds"
|
||||
pass "clipboard capture preserves UTF-16LE form feeds"
|
||||
|
||||
# BOM-less UTF-16LE "A" is byte-identical to UTF-8 "A\0". The padding
|
||||
# heuristic intentionally resolves that ambiguity as UTF-16.
|
||||
capture_output=$(printf 'A' | iconv -f UTF-8 -t UTF-16LE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
|
||||
[[ $capture_output == '{"type":"text","text":"A"}' ]] || fail "clipboard capture decodes exact NUL-padded UTF-16LE text"
|
||||
pass "clipboard capture decodes exact NUL-padded UTF-16LE text"
|
||||
|
||||
capture_output=$(printf 'BE text' | iconv -f UTF-8 -t UTF-16BE | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
|
||||
[[ $capture_output == '{"type":"text","text":"BE text"}' ]] || fail "clipboard capture decodes exact NUL-padded UTF-16BE text"
|
||||
pass "clipboard capture decodes exact NUL-padded UTF-16BE text"
|
||||
|
||||
capture_output=$({ printf '\377\376'; printf '%s' 'Little endian 日本 😀' | iconv -f UTF-8 -t UTF-16LE; } | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
|
||||
[[ $capture_output == '{"type":"text","text":"Little endian 日本 😀"}' ]] || fail "clipboard capture decodes BOM-tagged UTF-16LE text"
|
||||
pass "clipboard capture decodes BOM-tagged UTF-16LE text"
|
||||
|
||||
capture_output=$({ printf '\376\377'; printf '%s' 'Big endian 日本 😀' | iconv -f UTF-8 -t UTF-16BE; } | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text)
|
||||
[[ $capture_output == '{"type":"text","text":"Big endian 日本 😀"}' ]] || fail "clipboard capture decodes BOM-tagged UTF-16BE text"
|
||||
pass "clipboard capture decodes BOM-tagged UTF-16BE text"
|
||||
|
||||
assert_ambiguous_utf16_falls_back() {
|
||||
local description="$1" value="$2" expected capture_output
|
||||
printf '%s' "$value" | iconv -f UTF-8 -t UTF-16LE >"$TMPDIR/ambiguous-utf16"
|
||||
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/ambiguous-utf16")
|
||||
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/ambiguous-utf16")
|
||||
[[ $capture_output == "$expected" ]] || fail "$description" "expected: $expected\nactual: $capture_output"
|
||||
pass "$description"
|
||||
}
|
||||
|
||||
assert_ambiguous_utf16_falls_back "clipboard capture leaves BOM-less UTF-16 punctuation undecoded" '—'
|
||||
assert_ambiguous_utf16_falls_back "clipboard capture leaves BOM-less UTF-16 CJK undecoded" '日本'
|
||||
assert_ambiguous_utf16_falls_back "clipboard capture leaves BOM-less UTF-16 surrogate pairs undecoded" '😀'
|
||||
|
||||
printf 'foo\0bar\0' >"$TMPDIR/nul-separated-utf8"
|
||||
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/nul-separated-utf8")
|
||||
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/nul-separated-utf8")
|
||||
[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves sparse NUL-separated UTF-8 undecoded" "expected: $expected\nactual: $capture_output"
|
||||
pass "clipboard capture leaves sparse NUL-separated UTF-8 undecoded"
|
||||
|
||||
printf 'Hello\0\0\0\0\0\0\0\0\0\0\0' >"$TMPDIR/nul-padded-utf8"
|
||||
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/nul-padded-utf8")
|
||||
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/nul-padded-utf8")
|
||||
[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves NUL-padded UTF-8 undecoded" "expected: $expected\nactual: $capture_output"
|
||||
pass "clipboard capture leaves NUL-padded UTF-8 undecoded"
|
||||
|
||||
printf '\001\000\001\000\001\000\001\000' >"$TMPDIR/ambiguous-control-text"
|
||||
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/ambiguous-control-text")
|
||||
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/ambiguous-control-text")
|
||||
[[ $capture_output == "$expected" ]] || fail "clipboard capture leaves endian-ambiguous control text undecoded" "expected: $expected\nactual: $capture_output"
|
||||
pass "clipboard capture leaves endian-ambiguous control text undecoded"
|
||||
|
||||
printf '\377\376\075\330' >"$TMPDIR/malformed-utf16"
|
||||
expected=$(jq -cRs '{type:"text", text:.}' <"$TMPDIR/malformed-utf16")
|
||||
capture_output=$(XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" text <"$TMPDIR/malformed-utf16")
|
||||
[[ $capture_output == "$expected" ]] || fail "clipboard capture falls back from malformed UTF-16" "expected: $expected\nactual: $capture_output"
|
||||
pass "clipboard capture falls back from malformed UTF-16"
|
||||
|
||||
capture_output=$(printf 'png-data' | XDG_RUNTIME_DIR="$TMPDIR" XDG_STATE_HOME="$TMPDIR/state" PATH="$TMPDIR/bin:$PATH" "$ROOT/shell/plugins/clipboard/capture.sh" image/png)
|
||||
image_path=$(jq -r '.path' <<<"$capture_output")
|
||||
jq -e '.type == "image" and .mime == "image/png" and (.capturedAt | type == "string")' <<<"$capture_output" >/dev/null || fail "clipboard capture records watched png images"
|
||||
|
||||
@@ -105,7 +105,7 @@ assert_lazy_stub() {
|
||||
"$test_home/.local/bin/$command" --version
|
||||
mapfile -t mise_calls <"$mise_history"
|
||||
|
||||
[[ ${mise_calls[0]} == "use -g $package" && ${mise_calls[1]} == "x $package -- $command --version" ]] ||
|
||||
[[ ${mise_calls[0]} == "use -g --quiet $package" && ${mise_calls[1]} == "x $package -- $command --version" ]] ||
|
||||
fail "$command lazy stub preserves its mise package"
|
||||
}
|
||||
|
||||
@@ -138,6 +138,24 @@ source "$ROOT/migrations/1785617047.sh" >/dev/null
|
||||
source "$ROOT/migrations/1785846769.sh" >/dev/null
|
||||
[[ ! -s $stub_log ]] || fail "agent migrations respect the preinstall opt-out"
|
||||
[[ ! -e $test_home/.local/bin/omp ]] || fail "agent migration removes the obsolete Oh My Pi wrapper after opt-out"
|
||||
|
||||
# The matcher has to catch a bare oh-my-pi wrapper from either generation of the
|
||||
# installer, and leave a wrapper built on the fully qualified package alone.
|
||||
for obsolete_form in 'mise use -g "oh-my-pi"' 'mise use -g --quiet "oh-my-pi"'; do
|
||||
printf '#!/bin/bash\n%s || exit 1\n' "$obsolete_form" >"$test_home/.local/bin/omp"
|
||||
chmod +x "$test_home/.local/bin/omp"
|
||||
source "$ROOT/migrations/1785846769.sh" >/dev/null
|
||||
[[ ! -e $test_home/.local/bin/omp ]] ||
|
||||
fail "agent migration removes a wrapper built on [$obsolete_form]"
|
||||
done
|
||||
|
||||
printf '#!/bin/bash\nmise use -g --quiet "%s" || exit 1\n' "$omp_package" >"$test_home/.local/bin/omp"
|
||||
chmod +x "$test_home/.local/bin/omp"
|
||||
source "$ROOT/migrations/1785846769.sh" >/dev/null
|
||||
[[ -e $test_home/.local/bin/omp ]] ||
|
||||
fail "agent migration keeps a wrapper built on $omp_package"
|
||||
rm -f "$test_home/.local/bin/omp"
|
||||
|
||||
rm "$test_home/.local/state/omarchy/preinstalls-removed"
|
||||
pass "agent migrations install working wrappers without overriding the preinstall opt-out"
|
||||
|
||||
@@ -351,8 +369,8 @@ assert_bypass() {
|
||||
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 claude claude --permission-mode bypassPermissions -- "Review this project"
|
||||
assert_launch codex codex --dangerously-bypass-approvals-and-sandbox -- "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 crush crush run "Review this project"
|
||||
assert_launch grok grok --permission-mode bypassPermissions -- "Review this project"
|
||||
assert_launch gemini gemini --yolo --prompt-interactive "Review this project"
|
||||
@@ -362,8 +380,8 @@ pass "agent launcher adapts initial prompts for every supported agent"
|
||||
assert_bypass pi pi
|
||||
assert_bypass omp omp --auto-approve
|
||||
assert_bypass opencode opencode --auto
|
||||
assert_bypass claude claude --permission-mode bypassPermissions
|
||||
assert_bypass codex codex --dangerously-bypass-approvals-and-sandbox
|
||||
assert_bypass claude claude --permission-mode auto
|
||||
assert_bypass codex codex --approve-for-me
|
||||
assert_bypass crush crush --yolo
|
||||
assert_bypass grok grok --permission-mode bypassPermissions
|
||||
assert_bypass gemini gemini --yolo
|
||||
|
||||
Executable
+161
@@ -0,0 +1,161 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/base-test.sh"
|
||||
|
||||
dns="$ROOT/bin/omarchy-dns"
|
||||
sudoers_file="$ROOT/etc/sudoers.d/omarchy-dns"
|
||||
rule='%wheel ALL=(root) NOPASSWD: /usr/bin/omarchy-dns Cloudflare, /usr/bin/omarchy-dns Google, /usr/bin/omarchy-dns DHCP'
|
||||
|
||||
# Exactly one rule, matched whole. A second line -- or the same command with its
|
||||
# arguments dropped, which sudoers reads as "any arguments" -- would widen the
|
||||
# grant while leaving this line in place.
|
||||
rules=$(grep -vE '^[[:space:]]*(#|$)' "$sudoers_file")
|
||||
[[ $rules == "$rule" ]] ||
|
||||
fail "dns sudoers file carries exactly the stock-provider rule and nothing else" "got: $rules"
|
||||
|
||||
if command -v visudo >/dev/null; then
|
||||
visudo -cf "$sudoers_file" >/dev/null || fail "dns sudoers rule parses"
|
||||
fi
|
||||
|
||||
grep -Fx 'PACKAGED_PATH=/usr/bin/omarchy-dns' "$dns" >/dev/null ||
|
||||
fail "omarchy-dns elevates the path the sudoers rule names"
|
||||
|
||||
# `sudo -l` on its own answers whether a command is permitted, not whether it
|
||||
# is passwordless, and Omarchy ships a blanket %wheel rule that permits
|
||||
# everything. Only the long listing prints the matched entry's tags.
|
||||
grep -E 'sudo -n -l -l' "$dns" >/dev/null ||
|
||||
fail "omarchy-dns reads the grant from the long sudo listing"
|
||||
|
||||
pass "dns sudoers rule is scoped to the stock providers"
|
||||
|
||||
# The privileged half runs as root under sudo's secure_path, and a dev link
|
||||
# (etc/sudoers.d/omarchy-dev-path) prepends a user-writable checkout bin/ to it.
|
||||
# Every helper the script calls by bare name -- dirname, install, tee, nmcli,
|
||||
# systemctl, awk -- is a system tool, so once it holds root the script pins PATH
|
||||
# to trusted system directories and never resolves one of them out of the
|
||||
# checkout. The unprivileged wrapper phase keeps the caller's PATH, which is why
|
||||
# the pin is gated on EUID rather than set unconditionally.
|
||||
grep -Eq '^\s*export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin' "$dns" ||
|
||||
fail "omarchy-dns pins PATH to trusted system directories when it holds root"
|
||||
# require_root carries its own `(( EUID == 0 ))`, so matching that text alone
|
||||
# would pass with the pin deleted. Anchor on the unindented guard and require the
|
||||
# pin to be the line it opens.
|
||||
gated=$(grep -A1 -E '^if \(\( EUID == 0 \)\); then$' "$dns" || true)
|
||||
[[ $gated == *"export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin"* ]] ||
|
||||
fail "omarchy-dns gates the trusted-PATH pin on holding root"
|
||||
|
||||
# The no-argument path only reads DNS config, so exercise the privileged phase
|
||||
# directly when the suite is root and as namespaced root otherwise. This reaches
|
||||
# tr while EUID is 0 without giving an ordinary test run any host privileges.
|
||||
root_runner=()
|
||||
if (( EUID != 0 )); then
|
||||
root_runner=(unshare --user --map-root-user)
|
||||
fi
|
||||
|
||||
# A sandbox or a hardened kernel can refuse unprivileged user namespaces, and
|
||||
# the non-graphical suites have to stay green on any machine -- a skip is a
|
||||
# passing test. Only the runtime probe needs the namespace; the static checks
|
||||
# above and the elevation checks below run either way.
|
||||
if (( EUID == 0 )) || unshare --user --map-root-user true 2>/dev/null; then
|
||||
poison_dir=$(mktemp -d)
|
||||
poison_ran="$poison_dir/ran"
|
||||
for helper in tr awk dirname install tee; do
|
||||
cat >"$poison_dir/$helper" <<SH
|
||||
#!/bin/bash
|
||||
printf 'x' >"$poison_ran"
|
||||
exec "/usr/bin/$helper" "\$@"
|
||||
SH
|
||||
chmod +x "$poison_dir/$helper"
|
||||
done
|
||||
|
||||
if ! PATH="$poison_dir:$PATH" "${root_runner[@]}" bash "$dns" </dev/null >/dev/null 2>&1; then
|
||||
rm -rf "$poison_dir"
|
||||
fail "root omarchy-dns failed its read-only trusted-PATH probe"
|
||||
fi
|
||||
if [[ -e $poison_ran ]]; then
|
||||
rm -rf "$poison_dir"
|
||||
fail "root omarchy-dns resolved a bare helper from the front of PATH instead of a trusted system path"
|
||||
fi
|
||||
rm -rf "$poison_dir"
|
||||
pass "root omarchy-dns resolves system helpers from a trusted PATH, not the invocation PATH"
|
||||
else
|
||||
pass "no unprivileged user namespace; skipping the root trusted-PATH probe"
|
||||
fi
|
||||
|
||||
# require_root returns immediately for root, so the stubs below would not stand
|
||||
# between the script and the host's real NetworkManager and resolved config.
|
||||
if (( EUID == 0 )); then
|
||||
pass "running as root; skipping the elevation checks, which would rewrite this machine's DNS"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
test_tmp=$(mktemp -d)
|
||||
trap 'rm -rf "$test_tmp"' EXIT
|
||||
|
||||
stub_bin="$test_tmp/bin"
|
||||
mkdir -p "$stub_bin"
|
||||
|
||||
# pkexec stands in for the exec at the end of require_root, so the DNS writes
|
||||
# below it never run and real pkexec is never reached.
|
||||
cat >"$stub_bin/pkexec" <<'SH'
|
||||
#!/bin/bash
|
||||
printf 'pkexec %s\n' "$*" >"$ELEVATION_LOG"
|
||||
SH
|
||||
chmod +x "$stub_bin/pkexec"
|
||||
|
||||
# The sudo stub plays both parts: it answers the passwordless probe from
|
||||
# STUB_GRANTED, the providers etc/sudoers.d/omarchy-dns covers on this machine,
|
||||
# and logs the elevation otherwise. STUB_GRANTED empty stands for an install
|
||||
# whose omarchy-settings predates the file.
|
||||
cat >"$stub_bin/sudo" <<'SH'
|
||||
#!/bin/bash
|
||||
if [[ $1 == -n && $2 == -l ]]; then
|
||||
for granted in ${STUB_GRANTED-Cloudflare Google DHCP}; do
|
||||
[[ ${!#} == "$granted" ]] || continue
|
||||
echo " Options: !authenticate"
|
||||
exit 0
|
||||
done
|
||||
echo " Matched: ${!#}"
|
||||
exit 0
|
||||
fi
|
||||
printf 'sudo %s\n' "$*" >"$ELEVATION_LOG"
|
||||
SH
|
||||
chmod +x "$stub_bin/sudo"
|
||||
|
||||
elevation_for() {
|
||||
: >"$test_tmp/elevation"
|
||||
ELEVATION_LOG="$test_tmp/elevation" \
|
||||
PATH="$stub_bin:$PATH" \
|
||||
bash "$dns" "$1" </dev/null >/dev/null
|
||||
cat "$test_tmp/elevation"
|
||||
}
|
||||
|
||||
for provider in Cloudflare Google DHCP; do
|
||||
elevation=$(elevation_for "$provider")
|
||||
[[ $elevation == "sudo /usr/bin/omarchy-dns $provider" ]] ||
|
||||
fail "omarchy-dns takes the passwordless sudo grant for $provider without a terminal" "got: $elevation"
|
||||
done
|
||||
|
||||
pass "omarchy-dns elevates the stock providers through sudo, not polkit"
|
||||
|
||||
# A dev-linked checkout elevates the packaged path like everyone else, rather
|
||||
# than handing sudo a path no rule can name and losing the grant.
|
||||
dev_linked=$(OMARCHY_PATH="$test_tmp/checkout" elevation_for Cloudflare)
|
||||
[[ $dev_linked == "sudo /usr/bin/omarchy-dns Cloudflare" ]] ||
|
||||
fail "omarchy-dns elevates the system install wherever OMARCHY_PATH points" "got: $dev_linked"
|
||||
|
||||
custom=$(elevation_for Custom)
|
||||
[[ $custom == "pkexec /usr/bin/omarchy-dns Custom" ]] ||
|
||||
fail "omarchy-dns leaves Custom on the polkit path, since no sudoers rule covers it" "got: $custom"
|
||||
|
||||
# The grant is what makes sudo passwordless, so its absence -- an install still
|
||||
# on an older omarchy-settings, or a user outside %wheel the rule cannot match
|
||||
# -- has to route to polkit. Betting on sudo here leaves the panel's one-click
|
||||
# toggle execing into a password prompt it has no terminal to show.
|
||||
ungranted=$(STUB_GRANTED="" elevation_for Cloudflare)
|
||||
[[ $ungranted == "pkexec /usr/bin/omarchy-dns Cloudflare" ]] ||
|
||||
fail "omarchy-dns falls back to polkit where the sudoers grant is not installed" "got: $ungranted"
|
||||
|
||||
pass "omarchy-dns falls back to polkit wherever the grant does not reach"
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# The docker-group opt-in migration must remove an existing install's user from
|
||||
# the root-equivalent docker group (only when they are in it), flag a reboot so
|
||||
# the group change actually takes effect (group membership is fixed at login),
|
||||
# refresh the stale Docker launcher entry, and stay idempotent on reruns.
|
||||
#
|
||||
# The real omarchy-remove-security-sudoless-docker and omarchy-state run here
|
||||
# (from the repo bin); only the privileged/system calls are stubbed, so the whole
|
||||
# chain — including the reboot flag — is exercised.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
||||
|
||||
migration="$ROOT/migrations/1787580187.sh"
|
||||
test_dir=$(mktemp -d)
|
||||
trap 'rm -rf "$test_dir"' EXIT
|
||||
|
||||
home="$test_dir/home"
|
||||
omarchy_path="$test_dir/omarchy"
|
||||
stub_bin="$test_dir/bin"
|
||||
mkdir -p "$home/.local/share/applications" "$omarchy_path/applications" "$stub_bin"
|
||||
|
||||
printf 'NEW-LAUNCHER\n' >"$omarchy_path/applications/Docker.desktop"
|
||||
printf 'OLD-LAUNCHER\n' >"$home/.local/share/applications/Docker.desktop"
|
||||
|
||||
# id reports a controllable group set; sudo just drops the prefix; gpasswd
|
||||
# records its call instead of touching the real system.
|
||||
cat >"$stub_bin/id" <<'STUB'
|
||||
#!/bin/bash
|
||||
printf '%s\n' "${STUB_GROUPS:-wheel input}"
|
||||
STUB
|
||||
cat >"$stub_bin/sudo" <<'STUB'
|
||||
#!/bin/bash
|
||||
exec "$@"
|
||||
STUB
|
||||
cat >"$stub_bin/gpasswd" <<'STUB'
|
||||
#!/bin/bash
|
||||
echo "$@" >>"${GPASSWD_CALLS:?}"
|
||||
STUB
|
||||
# gum confirm always says yes, and reboot records that it fired: the migration
|
||||
# must still NOT reboot (it defers to omarchy-update-restart), so neither should
|
||||
# be reached.
|
||||
cat >"$stub_bin/gum" <<'STUB'
|
||||
#!/bin/bash
|
||||
[[ $1 == confirm ]] && exit 0
|
||||
exit 0
|
||||
STUB
|
||||
cat >"$stub_bin/omarchy-system-reboot" <<'STUB'
|
||||
#!/bin/bash
|
||||
touch "${REBOOT_CALLED:?}"
|
||||
STUB
|
||||
chmod +x "$stub_bin/id" "$stub_bin/sudo" "$stub_bin/gpasswd" "$stub_bin/gum" "$stub_bin/omarchy-system-reboot"
|
||||
|
||||
reboot_flag="$home/.local/state/omarchy/reboot-required"
|
||||
gpasswd_calls="$test_dir/gpasswd-calls"
|
||||
reboot_called="$test_dir/reboot-called"
|
||||
launcher="$home/.local/share/applications/Docker.desktop"
|
||||
|
||||
run_migration() {
|
||||
rm -f "$gpasswd_calls" "$reboot_flag" "$reboot_called"
|
||||
HOME="$home" OMARCHY_PATH="$omarchy_path" USER="tester" STUB_GROUPS="$1" \
|
||||
GPASSWD_CALLS="$gpasswd_calls" REBOOT_CALLED="$reboot_called" \
|
||||
PATH="$stub_bin:$ROOT/bin:$PATH" \
|
||||
bash -euo pipefail "$migration" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# In the docker group: user removed, reboot flagged, launcher refreshed.
|
||||
run_migration "wheel input docker" || fail "migration runs when the user is in the docker group"
|
||||
grep -q -- "-d tester docker" "$gpasswd_calls" || fail "migration removes the user from the docker group"
|
||||
[[ -f $reboot_flag ]] || fail "migration flags a reboot so the group change takes effect"
|
||||
[[ ! -f $reboot_called ]] || fail "migration must defer the reboot (not reboot mid-update)"
|
||||
[[ $(cat "$launcher") == "NEW-LAUNCHER" ]] || fail "migration refreshes the stale Docker launcher entry"
|
||||
pass "migration removes the group, flags a reboot, and refreshes the launcher"
|
||||
|
||||
# Not in the docker group (fresh install, or already migrated): nothing changes.
|
||||
printf 'OLD-LAUNCHER\n' >"$launcher"
|
||||
run_migration "wheel input" || fail "migration runs when the user is not in the docker group"
|
||||
[[ ! -f $gpasswd_calls ]] || fail "migration must not touch the group when the user is not in it"
|
||||
[[ ! -f $reboot_flag ]] || fail "migration must not flag a reboot when nothing changed"
|
||||
[[ $(cat "$launcher") == "NEW-LAUNCHER" ]] || fail "migration still refreshes the launcher when the group is already absent"
|
||||
pass "migration is a no-op on the group and reboot when already out"
|
||||
|
||||
# No launcher entry present: the refresh is skipped without error.
|
||||
rm -f "$launcher"
|
||||
run_migration "wheel input" || fail "migration tolerates a missing launcher entry"
|
||||
[[ ! -e $launcher ]] || fail "migration does not create a launcher entry that was not there"
|
||||
pass "migration skips the launcher refresh when no entry exists"
|
||||
@@ -31,10 +31,12 @@ chmod +x "$test_bin/omarchy-hw-fingerprint"
|
||||
cat >"$test_bin/omarchy-notification-send" <<'EOF'
|
||||
#!/bin/bash
|
||||
echo notification >>"$TEST_LOG"
|
||||
exec_args=()
|
||||
while (($# > 0)); do
|
||||
[[ $1 == "--exec" ]] && echo "exec:$2" >>"$TEST_LOG"
|
||||
if [[ $1 == "--exec" ]]; then shift; exec_args=("$@"); break; fi
|
||||
shift
|
||||
done
|
||||
((${#exec_args[@]})) && echo "exec:${exec_args[*]}" >>"$TEST_LOG"
|
||||
EOF
|
||||
chmod +x "$test_bin/omarchy-notification-send"
|
||||
|
||||
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# omarchy-git-url-check decides which URLs omarchy-theme-install and
|
||||
# omarchy-plugin-add are willing to hand to `git clone`. git resolves a remote
|
||||
# helper -- a program it runs at clone time -- from exactly two URL shapes,
|
||||
# `<helper>::<address>` and `<scheme>://<address>`, so those are the two shapes
|
||||
# asserted here, alongside every legitimate form a user is likely to paste.
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
check() {
|
||||
"$ROOT/bin/omarchy-git-url-check" "$@" 2>&1
|
||||
}
|
||||
|
||||
# `<helper>::<address>`, the shape that runs a program. `ext::` is the dangerous
|
||||
# one: git runs the rest as a shell command once protocol.ext.allow permits it.
|
||||
for url in "ext::sh -c id" "fd::0,1" "gcrypt::x" "a+b::x" "a.b::x" "a-b::x" "1::x"; do
|
||||
output=$(check "$url") &&
|
||||
fail "omarchy-git-url-check refuses the transport helper '$url'" "$output"
|
||||
grep -qF "names a git option or transport helper" <<<"$output" ||
|
||||
fail "omarchy-git-url-check names the helper rejection for '$url'" "$output"
|
||||
done
|
||||
|
||||
pass "a <helper>::<address> URL is refused"
|
||||
|
||||
# `<scheme>://<address>`, the shape #8067 left open: git looks up
|
||||
# git-remote-<scheme> for any scheme it does not implement itself, so an
|
||||
# allowlist is the only form of this check that holds.
|
||||
for url in "ext://sh -c id" "fd://17" "gcrypt://example.com/x" "zzz://a" "ZZZ://a" "HTTPS://github.com/a/b"; do
|
||||
output=$(check "$url") &&
|
||||
fail "omarchy-git-url-check refuses the '$url' transport" "$output"
|
||||
grep -qF "which Omarchy does not clone from" <<<"$output" ||
|
||||
fail "omarchy-git-url-check names the transport rejection for '$url'" "$output"
|
||||
done
|
||||
|
||||
pass "a <scheme>://<address> URL outside git's own transports is refused"
|
||||
|
||||
# A leading dash is an option to git, not a URL.
|
||||
for url in "-x" "--upload-pack=touch /tmp/pwned" "-oProxyCommand=x"; do
|
||||
output=$(check "$url") &&
|
||||
fail "omarchy-git-url-check refuses the option '$url'" "$output"
|
||||
done
|
||||
|
||||
pass "a URL shaped like a git option is refused"
|
||||
|
||||
output=$(check "") && fail "omarchy-git-url-check refuses an empty URL" "$output"
|
||||
output=$(check) && fail "omarchy-git-url-check refuses a missing URL" "$output"
|
||||
|
||||
pass "an empty URL is refused"
|
||||
|
||||
# Everything a user actually pastes. The scp-style forms carry a single colon,
|
||||
# which git never reads as a helper, and the IPv6 host carries `::` inside
|
||||
# brackets rather than at the start.
|
||||
for url in \
|
||||
"https://github.com/acme/omarchy-weather.git" \
|
||||
"http://example.com/a/b.git" \
|
||||
"https://user:token@github.com/acme/repo.git" \
|
||||
"ssh://git@github.com/acme/repo.git" \
|
||||
"ssh://git@[2001:db8::1]:22/org/repo.git" \
|
||||
"git://example.com/repo.git" \
|
||||
"git+ssh://git@example.com/acme/repo.git" \
|
||||
"ssh+git://git@example.com/acme/repo.git" \
|
||||
"ftp://example.com/repo.git" \
|
||||
"ftps://example.com/repo.git" \
|
||||
"file:///home/me/repo" \
|
||||
"git@github.com:acme/repo.git" \
|
||||
"git@[2001:db8::1]:org/repo.git" \
|
||||
"host:-s/foo.git" \
|
||||
"/home/me/repo" \
|
||||
"./repo" \
|
||||
"../repo" \
|
||||
"repo"; do
|
||||
output=$(check "$url") ||
|
||||
fail "omarchy-git-url-check accepts the legitimate URL '$url'" "$output"
|
||||
done
|
||||
|
||||
pass "the URL forms a user pastes are accepted"
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
require_command lua
|
||||
|
||||
run_paths() {
|
||||
lua - <<'LUA'
|
||||
package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path
|
||||
local paths = require("default.hypr.paths")
|
||||
assert(paths.config_home == os.getenv("EXPECTED_CONFIG"), "config_home: " .. paths.config_home)
|
||||
assert(paths.state_home == os.getenv("EXPECTED_STATE"), "state_home: " .. paths.state_home)
|
||||
LUA
|
||||
}
|
||||
|
||||
HOME="/home/test-user" OMARCHY_PATH="$ROOT" \
|
||||
XDG_CONFIG_HOME= XDG_STATE_HOME= \
|
||||
EXPECTED_CONFIG="/home/test-user/.config" EXPECTED_STATE="/home/test-user/.local/state" \
|
||||
run_paths
|
||||
pass "empty XDG path variables fall back to their defaults"
|
||||
|
||||
HOME="/home/test-user" OMARCHY_PATH="$ROOT" \
|
||||
XDG_CONFIG_HOME="/custom/config" XDG_STATE_HOME="/custom/state" \
|
||||
EXPECTED_CONFIG="/custom/config" EXPECTED_STATE="/custom/state" \
|
||||
run_paths
|
||||
pass "set XDG path variables are honored"
|
||||
@@ -189,3 +189,82 @@ printf "survived\n"' 2>/dev/null)
|
||||
[[ $errexit_result == $'hit:c:1\nmiss:c:0\nsurvived' ]] ||
|
||||
fail "guard batch survives a failing reader under errexit" "got: $errexit_result"
|
||||
pass "guard batch survives a reader that exits nonzero under errexit"
|
||||
|
||||
# Update > Extra Themes runs omarchy-theme-update, which pulls the themes that
|
||||
# came from a git clone and skips everything else, so the guard has to answer
|
||||
# for the same set: a row that appears over a symlinked theme or a worktree's
|
||||
# `.git` file opens a terminal that prints nothing and closes. Both sides ask
|
||||
# omarchy-theme-extras today; the shapes below are what would tell us if one
|
||||
# of them stopped.
|
||||
themes_guard=$(node -e '
|
||||
const fs = require("fs")
|
||||
const path = require("path")
|
||||
const menu = require(path.join(process.env.ROOT, "shell/plugins/menu/MenuModel.js"))
|
||||
const items = menu.parseMenuJsonc(fs.readFileSync(path.join(process.env.ROOT, "default/omarchy/omarchy-menu.jsonc"), "utf8"))
|
||||
process.stdout.write(items.find(item => item.id === "update.themes").when)
|
||||
')
|
||||
|
||||
cat >"$stub_dir/git" <<'STUB'
|
||||
#!/bin/bash
|
||||
: "${GIT_CALLS:=/dev/null}"
|
||||
{ printf '<%s>' "$@"; printf '\n'; } >>"$GIT_CALLS"
|
||||
STUB
|
||||
chmod +x "$stub_dir/git"
|
||||
|
||||
# The updater names each theme it pulls, so what it printed is what the row
|
||||
# would have been for. Run the guard the way the batch does, braces and all,
|
||||
# and say which shapes are meant to show it rather than only that the two
|
||||
# agree: they read the same command now, and agreement alone would hold even
|
||||
# if both went wrong together.
|
||||
assert_themes_guard_agrees() {
|
||||
local description="$1" home="$2" expected="$3"
|
||||
local guarded=0 updated=0
|
||||
|
||||
HOME="$home" PATH="$ROOT/bin:$PATH" bash -e -c "{ $themes_guard; } >/dev/null 2>&1" || guarded=$?
|
||||
[[ -n $(HOME="$home" PATH="$ROOT/bin:$stub_dir:$PATH" "$ROOT/bin/omarchy-theme-update" 2>/dev/null) ]] || updated=1
|
||||
((guarded == expected)) || fail "$description" "$home: guard=$guarded expected=$expected"
|
||||
((updated == expected)) || fail "$description" "$home: update=$updated expected=$expected"
|
||||
}
|
||||
|
||||
themes_home=$(mktemp -d)
|
||||
trap 'rm -rf "$stub_dir" "$themes_home"' EXIT
|
||||
|
||||
# A theme copied by hand has nothing to pull, a symlinked one is someone's
|
||||
# working copy, and a `.git` file is a worktree living elsewhere.
|
||||
mkdir -p "$themes_home/missing"
|
||||
mkdir -p "$themes_home/empty/.config/omarchy/themes"
|
||||
mkdir -p "$themes_home/copied/.config/omarchy/themes/handmade"
|
||||
mkdir -p "$themes_home/cloned/.config/omarchy/themes/tokyo-night/.git"
|
||||
mkdir -p "$themes_home/linked/.config/omarchy/themes" "$themes_home/checkout/.git"
|
||||
ln -s "$themes_home/checkout" "$themes_home/linked/.config/omarchy/themes/in-progress"
|
||||
mkdir -p "$themes_home/worktree/.config/omarchy/themes/branch"
|
||||
printf 'gitdir: /elsewhere\n' >"$themes_home/worktree/.config/omarchy/themes/branch/.git"
|
||||
|
||||
for shape in missing:1 empty:1 copied:1 cloned:0 linked:1 worktree:1; do
|
||||
assert_themes_guard_agrees \
|
||||
"Extra Themes shows exactly when omarchy-theme-update has something to pull" \
|
||||
"$themes_home/${shape%:*}" "${shape#*:}"
|
||||
done
|
||||
pass "Extra Themes shows exactly when omarchy-theme-update has something to pull"
|
||||
|
||||
# Which themes get pulled, not just that something did: a name with a space in
|
||||
# it is the one that goes missing the moment a path is split rather than passed
|
||||
# whole, and it would still print an Updating: line on its way to the wrong
|
||||
# directory.
|
||||
many="$themes_home/many/.config/omarchy/themes"
|
||||
mkdir -p "$many/tokyo night/.git" "$many/zen/.git" "$many/handmade"
|
||||
ln -s "$themes_home/checkout" "$many/in-progress"
|
||||
|
||||
listed=$(HOME="$themes_home/many" LC_ALL=C "$ROOT/bin/omarchy-theme-extras")
|
||||
[[ $listed == "$many/tokyo night"$'\n'"$many/zen" ]] ||
|
||||
fail "omarchy-theme-extras lists every clone and nothing else" "got: $listed"
|
||||
pass "omarchy-theme-extras lists every clone and nothing else"
|
||||
|
||||
git_calls=$(mktemp)
|
||||
trap 'rm -rf "$stub_dir" "$themes_home" "$git_calls"' EXIT
|
||||
HOME="$themes_home/many" LC_ALL=C GIT_CALLS="$git_calls" PATH="$ROOT/bin:$stub_dir:$PATH" \
|
||||
"$ROOT/bin/omarchy-theme-update" >/dev/null 2>&1
|
||||
pulled=$(<"$git_calls")
|
||||
[[ $pulled == "<-C><$many/tokyo night><pull>"$'\n'"<-C><$many/zen><pull>" ]] ||
|
||||
fail "omarchy-theme-update pulls each clone by its whole path" "got: $pulled"
|
||||
pass "omarchy-theme-update pulls each clone by its whole path"
|
||||
@@ -111,7 +111,7 @@ pick clone "$(printf 'Clock\tomarchy.clock')"
|
||||
[[ $ROWS == *"Clock"* && $ROWS != *"Weather"* ]] ||
|
||||
fail "clone picker offers only built-in plugins" "$ROWS"
|
||||
pass "clone picker offers built-in plugins"
|
||||
[[ $CALLS == *"terminal: omarchy-plugin-clone --edit omarchy.clock"* ]] ||
|
||||
[[ $CALLS == *"terminal: omarchy-plugin-clone omarchy.clock --edit"* ]] ||
|
||||
fail "clone picker delegates cloning and editing to the clone command" "$CALLS"
|
||||
pass "clone picker clones and opens the personal plugin"
|
||||
|
||||
|
||||
@@ -172,6 +172,11 @@ assert(
|
||||
defaultById['update.omarchy'].iconFont === 'omarchy',
|
||||
'menu update Omarchy entry renders the private glyph with the Omarchy font'
|
||||
)
|
||||
assertEqual(
|
||||
defaultById['update.themes'].when,
|
||||
'omarchy-theme-extras',
|
||||
'menu hides Extra Themes until a theme cloned from git is there to update'
|
||||
)
|
||||
assert(
|
||||
defaultById['setup.input'].action.includes('input.lua'),
|
||||
'menu keeps Input as a direct config action'
|
||||
|
||||
@@ -160,6 +160,7 @@ run_notify 1 >/dev/null 2>&1
|
||||
notify_args_written || fail "migration notifier sends the notification before exiting"
|
||||
grep -Fx -- '--exec' "$test_tmp/notify-args" >/dev/null ||
|
||||
fail "migration notifier attaches the click command to the toast"
|
||||
grep -Fx 'omarchy-launch-floating-terminal-with-presentation omarchy-migrate' "$test_tmp/notify-args" >/dev/null ||
|
||||
grep -Fx 'omarchy-launch-floating-terminal-with-presentation' "$test_tmp/notify-args" >/dev/null &&
|
||||
grep -Fx 'omarchy-migrate' "$test_tmp/notify-args" >/dev/null ||
|
||||
fail "migration notifier points the click command at omarchy-migrate"
|
||||
pass "migration notifier lets the shell own the click instead of waiting for it"
|
||||
@@ -65,6 +65,18 @@ local omarchy_monitor_scale = "auto"
|
||||
LUA
|
||||
}
|
||||
|
||||
# The shipped default's own shape: the catch-all rule hands the panel a
|
||||
# bare-word reference to the "auto" local.
|
||||
write_default_auto_config() {
|
||||
cat >"$monitor_lua" <<'LUA'
|
||||
local omarchy_gdk_scale = 2
|
||||
local omarchy_monitor_scale = "auto"
|
||||
|
||||
hl.env("GDK_SCALE", tostring(omarchy_gdk_scale))
|
||||
hl.monitor({ output = "", mode = "preferred", position = "auto", scale = omarchy_monitor_scale })
|
||||
LUA
|
||||
}
|
||||
|
||||
write_internal_monitor_config() {
|
||||
cat >"$monitor_lua" <<'LUA'
|
||||
hl.monitor({ output = "eDP-1", mode = "preferred", position = "0x0", scale = 1.25 })
|
||||
@@ -208,20 +220,39 @@ run_clamshell() {
|
||||
"$ROOT/bin/omarchy-hyprland-monitor-clamshell"
|
||||
}
|
||||
|
||||
# Regression (#7265, #7301): with scale = "auto" the compositor's resolution of
|
||||
# it IS the configured scale, not a transient to correct. Forcing a number here
|
||||
# made the scale flap: every idle-wake applied the fallback 2, every config
|
||||
# reload resolved auto back to the panel's own value.
|
||||
write_auto_monitor_config
|
||||
: >"$eval_log"
|
||||
OMARCHY_TEST_INTERNAL_SCALE=3 run_clamshell
|
||||
grep -F 'scale = 2' "$eval_log" >/dev/null || fail "clamshell recovery ignores transient auto scale"
|
||||
! grep -F 'scale = "auto"' "$eval_log" >/dev/null || fail "clamshell recovery does not apply auto scale"
|
||||
! grep -F 'scale = ' "$eval_log" >/dev/null || fail "clamshell recovery leaves an auto-scaled panel alone"
|
||||
[[ ! -f $scale_state ]] || fail "clamshell recovery does not remember transient scale"
|
||||
pass "clamshell recovery ignores transient auto scale"
|
||||
pass "clamshell recovery leaves an auto-scaled panel alone"
|
||||
|
||||
write_auto_monitor_config
|
||||
# The same delegation through the shipped default config, where "auto" reaches
|
||||
# the panel via the catch-all rule's bare-word reference to the local.
|
||||
write_default_auto_config
|
||||
: >"$eval_log"
|
||||
OMARCHY_TEST_INTERNAL_SCALE=2 run_clamshell
|
||||
OMARCHY_TEST_INTERNAL_SCALE=3 run_clamshell
|
||||
! grep -F 'scale = ' "$eval_log" >/dev/null || fail "clamshell recovery leaves the shipped auto default alone"
|
||||
pass "clamshell recovery leaves the shipped auto default alone"
|
||||
|
||||
# A numeric config keeps both sync behaviors: a matching active scale is not
|
||||
# reapplied, and a drifted one is corrected back to the configured value.
|
||||
write_internal_monitor_config
|
||||
: >"$eval_log"
|
||||
OMARCHY_TEST_INTERNAL_SCALE=1.25 run_clamshell
|
||||
! grep -F 'scale = ' "$eval_log" >/dev/null || fail "clamshell recovery does not reapply matching scale"
|
||||
pass "clamshell recovery avoids redundant scale apply"
|
||||
|
||||
write_internal_monitor_config
|
||||
: >"$eval_log"
|
||||
OMARCHY_TEST_INTERNAL_SCALE=3 run_clamshell
|
||||
grep -F 'scale = 1.25' "$eval_log" >/dev/null || fail "clamshell recovery corrects a drifted numeric scale"
|
||||
pass "clamshell recovery corrects a drifted numeric scale"
|
||||
|
||||
write_auto_monitor_config
|
||||
: >"$eval_log"
|
||||
OMARCHY_TEST_INTERNAL_SCALE=1.6 OMARCHY_TEST_EXTERNAL_ACTIVE=true OMARCHY_TEST_CLAMSHELL=true run_clamshell
|
||||
@@ -235,6 +266,15 @@ grep -F 'scale = 1.6' "$eval_log" >/dev/null || fail "clamshell recovery uses re
|
||||
! grep -F 'scale = "auto"' "$eval_log" >/dev/null || fail "clamshell recovery avoids auto after disabled internal display"
|
||||
pass "clamshell recovery uses remembered internal scale"
|
||||
|
||||
# Recovery of a panel that is off, under an auto config with nothing
|
||||
# remembered, still needs a number: the historical default 2.
|
||||
write_auto_monitor_config
|
||||
rm -f "$scale_state"
|
||||
: >"$eval_log"
|
||||
OMARCHY_TEST_INTERNAL_DISABLED=true run_clamshell
|
||||
grep -F 'scale = 2' "$eval_log" >/dev/null || fail "clamshell recovery falls back to the default scale"
|
||||
pass "clamshell recovery falls back to the default scale"
|
||||
|
||||
write_internal_monitor_config
|
||||
: >"$eval_log"
|
||||
OMARCHY_TEST_INTERNAL_DISABLED=true run_clamshell
|
||||
@@ -307,6 +347,15 @@ OMARCHY_TEST_INTERNAL_DISABLED=true run_clamshell
|
||||
grep -F 'scale = 1.75' "$eval_log" >/dev/null || fail "clamshell recovery leaves an expression scale unresolved"
|
||||
pass "clamshell recovery leaves an expression scale unresolved"
|
||||
|
||||
# An expression is Hyprland's to evaluate, not this parser's: like "auto", it
|
||||
# names no number to correct an enabled panel toward.
|
||||
write_expression_scale_config
|
||||
remember_scale 1.75
|
||||
: >"$eval_log"
|
||||
OMARCHY_TEST_INTERNAL_SCALE=3 run_clamshell
|
||||
! grep -F 'scale = ' "$eval_log" >/dev/null || fail "clamshell recovery leaves an expression-scaled panel alone"
|
||||
pass "clamshell recovery leaves an expression-scaled panel alone"
|
||||
|
||||
# A quoted value is a string, not a reference to a local of the same name.
|
||||
write_shadowed_auto_config
|
||||
remember_scale 1.75
|
||||
@@ -315,6 +364,15 @@ OMARCHY_TEST_INTERNAL_DISABLED=true run_clamshell
|
||||
grep -F 'scale = 1.75' "$eval_log" >/dev/null || fail "clamshell recovery does not resolve a quoted scale against a local"
|
||||
pass "clamshell recovery does not resolve a quoted scale against a local"
|
||||
|
||||
# An explicit "auto" on the internal rule delegates just the same: while the
|
||||
# panel is enabled, it is not corrected toward the remembered scale.
|
||||
write_shadowed_auto_config
|
||||
remember_scale 1.75
|
||||
: >"$eval_log"
|
||||
OMARCHY_TEST_INTERNAL_SCALE=3 run_clamshell
|
||||
! grep -F 'scale = ' "$eval_log" >/dev/null || fail "clamshell recovery leaves an explicitly auto panel alone"
|
||||
pass "clamshell recovery leaves an explicitly auto panel alone"
|
||||
|
||||
# Half of `3 / 2` is no better inside the rule than inside a local.
|
||||
write_expression_rule_config
|
||||
remember_scale 1.75
|
||||
|
||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user