Compare commits
No files matched your search
@@ -61,19 +61,18 @@ its screenshots are also hosted.
|
|||||||
- [Prompt](manual/40-prompt.md)
|
- [Prompt](manual/40-prompt.md)
|
||||||
- [Branding](manual/41-branding.md)
|
- [Branding](manual/41-branding.md)
|
||||||
- [Common tweaks](manual/42-common-tweaks.md)
|
- [Common tweaks](manual/42-common-tweaks.md)
|
||||||
- [Extra themes](manual/43-extra-themes.md)
|
- [Making your own theme](manual/43-making-your-own-theme.md)
|
||||||
- [Making your own theme](manual/44-making-your-own-theme.md)
|
|
||||||
|
|
||||||
**The Rest**
|
**The Rest**
|
||||||
|
|
||||||
- [Mac support](manual/45-mac-support.md)
|
- [Mac support](manual/44-mac-support.md)
|
||||||
- [Troubleshooting](manual/46-troubleshooting.md)
|
- [Troubleshooting](manual/45-troubleshooting.md)
|
||||||
- [FAQ](manual/47-faq.md)
|
- [FAQ](manual/46-faq.md)
|
||||||
- [System snapshots](manual/48-system-snapshots.md)
|
- [System snapshots](manual/47-system-snapshots.md)
|
||||||
- [Security](manual/49-security.md)
|
- [Security](manual/48-security.md)
|
||||||
- [Omarchy on...](manual/50-omarchy-on.md)
|
- [Omarchy on...](manual/49-omarchy-on.md)
|
||||||
- [Dual Boot Install](manual/51-dual-boot-install.md)
|
- [Dual Boot Install](manual/50-dual-boot-install.md)
|
||||||
- [Unattended Installs](manual/52-unattended-installs.md)
|
- [Unattended Installs](manual/51-unattended-installs.md)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Version=1.0
|
Version=1.0
|
||||||
Name=Docker
|
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
|
Terminal=false
|
||||||
Type=Application
|
Type=Application
|
||||||
Icon=docker
|
Icon=docker
|
||||||
|
|||||||
+7
-3
@@ -74,12 +74,16 @@ crush)
|
|||||||
command=(crush --yolo)
|
command=(crush --yolo)
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
claude | grok)
|
claude)
|
||||||
command=("$agent" --permission-mode bypassPermissions)
|
command=(claude --permission-mode auto)
|
||||||
|
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||||
|
;;
|
||||||
|
grok)
|
||||||
|
command=(grok --permission-mode bypassPermissions)
|
||||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||||
;;
|
;;
|
||||||
codex)
|
codex)
|
||||||
command=(codex --dangerously-bypass-approvals-and-sandbox)
|
command=(codex --approve-for-me)
|
||||||
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
|
||||||
;;
|
;;
|
||||||
omp)
|
omp)
|
||||||
|
|||||||
@@ -228,7 +228,7 @@ stop_screenrecording() {
|
|||||||
|
|
||||||
omarchy-notification-send "Screen recording saved" "Open with Super + Alt + , (or click this)" \
|
omarchy-notification-send "Screen recording saved" "Open with Super + Alt + , (or click this)" \
|
||||||
-t 10000 --image "${preview:-$filename}" \
|
-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
|
# 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
|
# 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.
|
# 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)" \
|
omarchy-notification-send "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" \
|
||||||
--image "$FILEPATH" \
|
--image "$FILEPATH" \
|
||||||
--exec "$(printf '%q %q' "$SCREENSHOT_EDITOR" "$FILEPATH")" || true
|
--exec "$SCREENSHOT_EDITOR" "$FILEPATH" || true
|
||||||
;;
|
;;
|
||||||
copy)
|
copy)
|
||||||
grim -g "$SELECTION" - | wl-copy --type image/png
|
grim -g "$SELECTION" - | wl-copy --type image/png
|
||||||
|
|||||||
@@ -25,6 +25,51 @@ valid_url() {
|
|||||||
[[ $1 =~ ^https?:// ]]
|
[[ $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
|
# Drive the Quickshell OSD — a single overlay that updates in place (like the
|
||||||
# volume/brightness bar), so download progress never stacks like notifications.
|
# volume/brightness bar), so download progress never stacks like notifications.
|
||||||
osd_progress() {
|
osd_progress() {
|
||||||
@@ -41,16 +86,19 @@ download_url() {
|
|||||||
mkdir -p "$DOWNLOAD_DIR"
|
mkdir -p "$DOWNLOAD_DIR"
|
||||||
|
|
||||||
# Don't show anything until yt-dlp confirms there's actually a video to grab.
|
# 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"
|
omarchy-notification-send -u critical -g "No video found for download" "$url"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
osd_progress 0
|
osd_progress 0
|
||||||
|
|
||||||
# Stream the download: OMARCHY_PROG carries the percent (drives the OSD),
|
# Stream the download: OMARCHY_PROG carries the percent (drives the OSD), and
|
||||||
# OMARCHY_FILE (printed only after a successful move) carries title + path.
|
# OMARCHY_FILE and OMARCHY_TITLE (printed only after a successful move) carry the
|
||||||
local line rest pct intpct last="" er nowms lastms=0 title="" filepath=""
|
# 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
|
while IFS= read -r line; do
|
||||||
case $line in
|
case $line in
|
||||||
OMARCHY_PROG*)
|
OMARCHY_PROG*)
|
||||||
@@ -67,22 +115,26 @@ download_url() {
|
|||||||
osd_progress "$intpct"
|
osd_progress "$intpct"
|
||||||
;;
|
;;
|
||||||
OMARCHY_FILE*)
|
OMARCHY_FILE*)
|
||||||
rest=${line#OMARCHY_FILE$'\t'}
|
resolved=$(resolve_download_file "${line#OMARCHY_FILE$'\t'}") || continue
|
||||||
title=${rest%%$'\t'*}
|
filepath=$resolved
|
||||||
filepath=${rest#*$'\t'}
|
;;
|
||||||
|
OMARCHY_TITLE*)
|
||||||
|
title=$(decode_title "${line#OMARCHY_TITLE$'\t'}") || title=""
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done < <(PYTHONUNBUFFERED=1 yt-dlp --no-playlist --restrict-filenames --no-simulate \
|
done < <(PYTHONUNBUFFERED=1 yt-dlp --no-playlist --no-simulate \
|
||||||
--quiet --no-warnings --progress --newline \
|
--quiet --no-warnings --no-exec --no-exec-before-download --progress --newline \
|
||||||
--progress-template $'download:OMARCHY_PROG\t%(progress._percent_str)s' \
|
--progress-template $'download:OMARCHY_PROG\t%(progress._percent_str)s' \
|
||||||
--paths "$DOWNLOAD_DIR" -o '%(title)s [%(id)s].%(ext)s' \
|
--paths "$DOWNLOAD_DIR" -o '%(title)s.%(ext)s' \
|
||||||
--print $'after_move:OMARCHY_FILE\t%(title)s\t%(filepath)s' \
|
--print $'after_move:OMARCHY_FILE\t%(filepath)s' \
|
||||||
"$url" 2>&1)
|
--print $'after_move:OMARCHY_TITLE\t%(title)j' \
|
||||||
|
-- "$url" 2>&1)
|
||||||
|
|
||||||
osd_close
|
osd_close
|
||||||
|
|
||||||
# after_move only prints on a successful download+move, so a captured path == success.
|
# after_move only prints on a successful download+move, so a captured path == success.
|
||||||
if [[ -n $filepath ]]; then
|
if [[ -n $filepath ]]; then
|
||||||
|
[[ -n $title ]] || title=$(title_from_file "$filepath")
|
||||||
((${#title} > 50)) && title="${title:0:50}…" # keep the toast compact
|
((${#title} > 50)) && title="${title:0:50}…" # keep the toast compact
|
||||||
|
|
||||||
# Square, center-cropped thumbnail so the notification preview isn't stretched.
|
# 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
|
# Best-effort: the download already succeeded, and under `set -e` a failed
|
||||||
# toast would exit before the thumbnail cleanup below is ever scheduled.
|
# 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" \
|
omarchy-notification-send -g "Download complete" "$title" \
|
||||||
-t 10000 --image "${preview:-$filepath}" \
|
-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
|
# 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
|
# 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
|
declare -A last_notified
|
||||||
|
|
||||||
announce() {
|
announce() {
|
||||||
local comm=$1 pid=$2 exe=$3 signal=$4 exec_command
|
local comm=$1 pid=$2 exe=$3 signal=$4
|
||||||
|
|
||||||
exec_command=$(printf 'omarchy-agent-crash %q %q %q %q' "$pid" "$comm" "$exe" "$signal")
|
|
||||||
|
|
||||||
# The shell owns org.freedesktop.Notifications, so a shell crash takes the
|
# 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.
|
# notification server down with it and a toast sent into that gap is lost.
|
||||||
@@ -35,14 +33,16 @@ announce() {
|
|||||||
omarchy-notification-wait || return 1
|
omarchy-notification-wait || return 1
|
||||||
|
|
||||||
# --exec rather than a libnotify action: the shell runs clicks from its own
|
# --exec rather than a libnotify action: the shell runs clicks from its own
|
||||||
# omarchy-exec hint and never emits ActionInvoked. Keeps the default
|
# hint and never emits ActionInvoked. Keeps the default "omarchy-action" app
|
||||||
# "omarchy-action" app name too, the only one shouldBypassDnd() lets through.
|
# 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 \
|
omarchy-notification-send \
|
||||||
--urgency critical \
|
--urgency critical \
|
||||||
--glyph "$CRASH_GLYPH" \
|
--glyph "$CRASH_GLYPH" \
|
||||||
--exec "$exec_command" \
|
|
||||||
"Process crashed: $comm" \
|
"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.
|
# -n 0 so a restart does not re-announce crashes already dealt with.
|
||||||
|
|||||||
+32
-7
@@ -6,6 +6,18 @@
|
|||||||
|
|
||||||
set -euo pipefail
|
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
|
NM_DNS_CONF=/etc/NetworkManager/conf.d/20-omarchy-dns.conf
|
||||||
|
|
||||||
provider_from_arg() {
|
provider_from_arg() {
|
||||||
@@ -28,19 +40,32 @@ provider_from_arg() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
self_path() {
|
# The path etc/sudoers.d/omarchy-dns names. The privileged half always runs from
|
||||||
readlink -f "$OMARCHY_PATH/bin/${BASH_SOURCE[0]##*/}"
|
# 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() {
|
require_root() {
|
||||||
if (( EUID == 0 )); then
|
if (( EUID == 0 )); then
|
||||||
return
|
return
|
||||||
fi
|
elif [[ -t 0 ]] || sudo_grants_passwordless "$@"; then
|
||||||
|
# A terminal can carry sudo's own password prompt. Without one, sudo is
|
||||||
if [[ -t 0 ]]; then
|
# right only where the grant reaches; polkit can at least put a prompt on
|
||||||
exec sudo "$(self_path)" "$@"
|
# screen, and offer to authenticate as someone else.
|
||||||
|
exec sudo "$PACKAGED_PATH" "$@"
|
||||||
else
|
else
|
||||||
exec pkexec "$(self_path)" "$@"
|
exec pkexec "$PACKAGED_PATH" "$@"
|
||||||
fi
|
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=$(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() {
|
valid_scale() {
|
||||||
[[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]]
|
[[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]]
|
||||||
}
|
}
|
||||||
@@ -118,9 +126,17 @@ remember_internal_scale() {
|
|||||||
store_internal_scale "$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() {
|
read_monitor_scale() {
|
||||||
local scale
|
local scale
|
||||||
scale=$(configured_monitor_scale)
|
|
||||||
|
if (( $# )); then
|
||||||
|
scale="$1"
|
||||||
|
else
|
||||||
|
scale=$(configured_monitor_scale)
|
||||||
|
fi
|
||||||
|
|
||||||
if valid_scale "$scale"; then
|
if valid_scale "$scale"; then
|
||||||
echo "$scale"
|
echo "$scale"
|
||||||
return
|
return
|
||||||
@@ -159,11 +175,25 @@ enable_internal_output() {
|
|||||||
|
|
||||||
sync_internal_scale() {
|
sync_internal_scale() {
|
||||||
[[ -n $INTERNAL ]] || return 0
|
[[ -n $INTERNAL ]] || return 0
|
||||||
|
local configured_scale
|
||||||
local desired_scale
|
local desired_scale
|
||||||
local active_scale
|
local active_scale
|
||||||
|
|
||||||
desired_scale=$(read_monitor_scale)
|
configured_scale=$(configured_monitor_scale)
|
||||||
active_scale=$(current_internal_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
|
scales_match "$active_scale" "$desired_scale" && return 0
|
||||||
|
|
||||||
enable_internal_output "$desired_scale"
|
enable_internal_output "$desired_scale"
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ off() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
if ! omarchy-hyprland-monitor-external-active; then
|
||||||
omarchy-notification-send -g "Can't disable the only active display"
|
omarchy-notification-send -g "Can't disable the only active display"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
@@ -22,6 +22,15 @@ on() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
omarchy-hyprland-toggle $DISABLE_TOGGLE off
|
||||||
|
|
||||||
if omarchy-hyprland-toggle-disabled $TOGGLE; then
|
if omarchy-hyprland-toggle-disabled $TOGGLE; then
|
||||||
|
|||||||
@@ -80,6 +80,14 @@ set_scale() {
|
|||||||
local width="$(echo "$monitor_info" | jq -r '.width')"
|
local width="$(echo "$monitor_info" | jq -r '.width')"
|
||||||
local height="$(echo "$monitor_info" | jq -r '.height')"
|
local height="$(echo "$monitor_info" | jq -r '.height')"
|
||||||
local refresh_rate="$(echo "$monitor_info" | jq -r '.refreshRate')"
|
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")"
|
local new_scale="$(clean_scale "$requested_scale" "$width" "$height")"
|
||||||
# GTK only honors integer GDK_SCALE values, so persist the nearest whole
|
# GTK only honors integer GDK_SCALE values, so persist the nearest whole
|
||||||
# factor even when the monitor scale itself is fractional.
|
# 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
|
if [[ $1 == "clone" ]]; then
|
||||||
omarchy-launch-floating-terminal-with-presentation \
|
omarchy-launch-floating-terminal-with-presentation \
|
||||||
"omarchy-plugin-clone --edit $(printf '%q' "$id")"
|
"omarchy-plugin-clone $(printf '%q' "$id") --edit"
|
||||||
elif [[ $1 == "remove" ]]; then
|
elif [[ $1 == "remove" ]]; then
|
||||||
omarchy-launch-floating-terminal-with-presentation "omarchy-plugin-remove $(printf '%q' "$id")"
|
omarchy-launch-floating-terminal-with-presentation "omarchy-plugin-remove $(printf '%q' "$id")"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ fi
|
|||||||
# The shell keeps the click command with the toast, so this oneshot can hand the
|
# 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.
|
# invitation over and exit instead of staying activated until it is answered.
|
||||||
omarchy-notification-send -u critical -g "Pending Omarchy Migrations" "$message" \
|
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
|
# Reached when the notification could not be handed off, so fall back to telling
|
||||||
# the user in the terminal.
|
# the user in the terminal.
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ rm -f "$HOME/.local/bin/$command"
|
|||||||
cat >"$HOME/.local/bin/$command" <<EOF
|
cat >"$HOME/.local/bin/$command" <<EOF
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
export MISE_MINIMUM_RELEASE_AGE=0
|
export MISE_MINIMUM_RELEASE_AGE=0
|
||||||
mise use -g "$package" || exit 1
|
mise use -g --quiet "$package" || exit 1
|
||||||
exec mise x "$package" -- "$bin" "\$@"
|
exec mise x "$package" -- "$bin" "\$@"
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
|||||||
+151
-63
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# omarchy:summary=Send an Omarchy desktop notification
|
# 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
|
# omarchy:examples=omarchy notification send "Reminder" "5 minutes are up" -g
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -11,61 +11,75 @@ description=""
|
|||||||
glyph=
|
glyph=
|
||||||
urgency="low"
|
urgency="low"
|
||||||
app_name="omarchy-action"
|
app_name="omarchy-action"
|
||||||
|
app_icon=""
|
||||||
image=
|
image=
|
||||||
exec_command=
|
expire_timeout=-1
|
||||||
args=()
|
replaces_id=0
|
||||||
|
print_id=0
|
||||||
|
exec_args=()
|
||||||
|
exec_present=0
|
||||||
parsed_option_args=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() {
|
parse_omarchy_option() {
|
||||||
case $1 in
|
local opt val nargs
|
||||||
-g | --glyph)
|
if [[ $1 == --?*=* ]]; then
|
||||||
if (($# < 2)); then
|
opt=${1%%=*}
|
||||||
echo "Missing value for $1" >&2
|
val=${1#*=}
|
||||||
exit 1
|
nargs=1
|
||||||
fi
|
else
|
||||||
glyph=$2
|
opt=$1
|
||||||
parsed_option_args=2
|
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
|
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)
|
-t | --expire-time)
|
||||||
if (($# < 2)); then
|
[[ $val =~ ^-?[0-9]+$ ]] || {
|
||||||
echo "Missing value for $1" >&2
|
echo "Invalid $opt value (milliseconds expected): $val" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
}
|
||||||
urgency="$2"
|
expire_timeout=$val
|
||||||
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
|
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
return 1
|
parsed_option_args=$nargs
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
while (($# > 0)); do
|
while (($# > 0)); do
|
||||||
@@ -77,47 +91,121 @@ while (($# > 0)); do
|
|||||||
done
|
done
|
||||||
|
|
||||||
if (($# < 1)); then
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
headline=$1
|
headline=$1
|
||||||
shift
|
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
|
description=$1
|
||||||
shift
|
shift
|
||||||
fi
|
fi
|
||||||
|
|
||||||
while (($# > 0)); do
|
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"
|
shift "$parsed_option_args"
|
||||||
else
|
else
|
||||||
args+=("$1")
|
echo "Unknown option: $1" >&2
|
||||||
shift
|
usage
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Tag as a user-action toast so it pops through DND.
|
case $urgency in
|
||||||
args+=("-a" "$app_name" "-u" "$urgency")
|
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
|
if [[ -n $glyph ]]; then
|
||||||
args+=("--hint=string:omarchy-glyph:$glyph")
|
hints+=(omarchy-glyph s "$glyph")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n $image ]]; then
|
if [[ -n $image ]]; then
|
||||||
args+=("--hint=string:image-path:$image")
|
hints+=(image-path s "$image")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# The shell runs the click command itself, from a copy it keeps alongside the
|
if ((exec_present)); then
|
||||||
# on-screen popup. A libnotify action would instead keep this process blocked
|
if ((${#exec_args[@]} == 0)) || [[ -z ${exec_args[0]} ]]; then
|
||||||
# until the click, and die unanswered whenever the shell restarts underneath it.
|
echo "--exec needs a command: --exec <program> [args...]" >&2
|
||||||
if [[ -n $exec_command ]]; then
|
exit 1
|
||||||
args+=("--hint=string:omarchy-exec:$exec_command")
|
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
|
fi
|
||||||
|
|
||||||
if [[ -n $description ]]; then
|
hint_count=$((${#hints[@]} / 3))
|
||||||
notify-send "${args[@]}" "$headline" "$description"
|
|
||||||
|
# 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
|
else
|
||||||
notify-send "${args[@]}" "$headline"
|
"${notify_cmd[@]}" >/dev/null
|
||||||
fi
|
fi
|
||||||
@@ -93,6 +93,13 @@ if [[ -z $url ]]; then
|
|||||||
[[ -n $url ]] || fail "a git URL is required"
|
[[ -n $url ]] || fail "a git URL is required"
|
||||||
fi
|
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
|
if (( ! ASSUME_YES )); then
|
||||||
cat >&2 <<WARN
|
cat >&2 <<WARN
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ if [[ ! -f $logo_path ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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_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_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}')
|
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" \
|
-e "s/#ffffff/#$text_hex/g" \
|
||||||
"$sddm_template" | sudo tee "$sddm_dir/Main.qml" >/dev/null
|
"$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
|
for asset in bullet.png entry.png lock.png; do
|
||||||
sudo cp "$staging_dir/$asset" "$sddm_dir/$asset"
|
sudo cp "$staging_dir/$asset" "$sddm_dir/$asset"
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -141,7 +141,8 @@ say() {
|
|||||||
notice() {
|
notice() {
|
||||||
clear_logo
|
clear_logo
|
||||||
echo
|
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
|
echo
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -676,6 +677,11 @@ user_groups() {
|
|||||||
if [[ -f $PROVISIONING_DIR/groups ]]; then
|
if [[ -f $PROVISIONING_DIR/groups ]]; then
|
||||||
while IFS= read -r group; do
|
while IFS= read -r group; do
|
||||||
[[ -n $group ]] || continue
|
[[ -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
|
getent group "$group" >/dev/null || continue
|
||||||
[[ ",$groups," == *",$group,"* ]] || groups+=",$group"
|
[[ ",$groups," == *",$group,"* ]] || groups+=",$group"
|
||||||
done <"$PROVISIONING_DIR/groups"
|
done <"$PROVISIONING_DIR/groups"
|
||||||
|
|||||||
@@ -24,9 +24,14 @@ echo -e "\e[32mRemoving FIDO2 device from authentication.\n\e[0m"
|
|||||||
|
|
||||||
remove_pam_config
|
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..."
|
echo "Removing FIDO2 configuration..."
|
||||||
sudo rm -rf /etc/fido2
|
sudo rm -rf "$authdir"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Removing FIDO2 packages..."
|
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
|
# 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.
|
# 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
|
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,
|
# Spawn from Hyprland so the shell inherits the canonical session environment,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
# omarchy:requires-sudo=true
|
# omarchy:requires-sudo=true
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
set -o pipefail
|
||||||
|
|
||||||
|
|
||||||
check_fido2_hardware() {
|
check_fido2_hardware() {
|
||||||
@@ -50,13 +51,79 @@ if ! check_fido2_hardware; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Create the pamu2fcfg file
|
# Create the pamu2fcfg file
|
||||||
if [[ ! -f /etc/fido2/fido2 ]]; then
|
authdir=/etc/fido2
|
||||||
sudo mkdir -p /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 "\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"
|
echo -e "Touch your FIDO2 key when it lights up...\n"
|
||||||
|
|
||||||
if pamu2fcfg >/tmp/fido2; then
|
# A unique sibling created by root cannot be replaced by another process
|
||||||
sudo mv /tmp/fido2 /etc/fido2/fido2
|
# 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"
|
echo -e "\e[32mFIDO2 device registered successfully!\e[0m"
|
||||||
else
|
else
|
||||||
echo -e "\e[31m\nFIDO2 registration failed. Please try again.\e[0m"
|
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
|
#!/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
|
# The device node is there whether or not a terminal is behind it, so opening
|
||||||
gum spin --spinner "globe" --title "Done! Press any key to close..." -- bash -c 'read -n 1 -s'
|
# 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() {
|
announce() {
|
||||||
local path="$1"
|
local path="$1"
|
||||||
local name="${path##*/}"
|
local name="${path##*/}"
|
||||||
local args=("Received $name" "Saved to ${dir/#$HOME/~}")
|
local args=("Received $name" "Saved to ${dir/#$HOME/~}" -u critical)
|
||||||
|
|
||||||
case "${name,,}" in
|
case "${name,,}" in
|
||||||
*.png | *.jpg | *.jpeg | *.gif | *.webp | *.avif | *.bmp | *.tif | *.tiff)
|
*.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`
|
# Announcing is best-effort: the file is already delivered, and under `set -e`
|
||||||
# a notification outage would otherwise kill the long-running receiver.
|
# 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() {
|
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
|
exit 1
|
||||||
fi
|
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"
|
THEMES_DIR="$HOME/.config/omarchy/themes"
|
||||||
|
|
||||||
# Strip user@host: prefix from scp-style SSH URLs so basename sees just the path
|
# Strip user@host: prefix from scp-style SSH URLs so basename sees just the path
|
||||||
REPO_PATH="$REPO_URL"
|
REPO_PATH="$REPO_URL"
|
||||||
[[ $REPO_PATH != *"://"* && $REPO_PATH == *:*/* ]] && REPO_PATH="${REPO_PATH#*:}"
|
[[ $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"
|
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
|
# Remove existing theme if present
|
||||||
if [[ -d $THEME_PATH ]]; then
|
if [[ -d $THEME_PATH ]]; then
|
||||||
rm -rf "$THEME_PATH"
|
rm -rf "$THEME_PATH"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Clone the repo directly to ~/.config/omarchy/themes
|
# 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."
|
echo "Error: Failed to clone theme repo."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Apply the new theme with omarchy-theme-set
|
# Apply the new theme with omarchy-theme-set, which stages only the files an
|
||||||
omarchy-theme-set $THEME_NAME
|
# 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"
|
THEMES_DIR="$HOME/.config/omarchy/themes"
|
||||||
THEME_PATH="$THEMES_DIR/$THEME_NAME"
|
THEME_PATH="$THEMES_DIR/$THEME_NAME"
|
||||||
|
|
||||||
# Ensure a theme was set
|
# Ensure a theme was set, and that the name cannot climb out of THEMES_DIR
|
||||||
if [[ -z $THEME_NAME ]]; then
|
# on its way into the rm below.
|
||||||
|
if [[ -z $THEME_NAME || $THEME_NAME == .* || $THEME_NAME == */* ]]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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"
|
USER_THEMES_PATH="$HOME/.config/omarchy/themes"
|
||||||
OMARCHY_THEMES_PATH="$OMARCHY_PATH/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() {
|
run_parallel() {
|
||||||
local pid
|
local pid
|
||||||
local pids=()
|
local pids=()
|
||||||
@@ -122,12 +135,121 @@ set_theme_background() {
|
|||||||
ln -nsf "$new_background" "$CURRENT_BACKGROUND_LINK"
|
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_NAME=$(echo "$1" | sed -E 's/<[^>]+>//g' | tr '[:upper:]' '[:lower:]' | tr ' ' '-')
|
||||||
THEME_HEADLESS=0
|
THEME_HEADLESS=0
|
||||||
if [[ ${OMARCHY_THEME_HEADLESS:-} == "1" || ${OMARCHY_THEME_OFFLINE:-} == "1" ]]; then
|
if [[ ${OMARCHY_THEME_HEADLESS:-} == "1" || ${OMARCHY_THEME_OFFLINE:-} == "1" ]]; then
|
||||||
THEME_HEADLESS=1
|
THEME_HEADLESS=1
|
||||||
fi
|
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
|
if [[ ! -d $OMARCHY_THEMES_PATH/$THEME_NAME ]] && [[ ! -d $USER_THEMES_PATH/$THEME_NAME ]]; then
|
||||||
echo "Theme '$THEME_NAME' does not exist"
|
echo "Theme '$THEME_NAME' does not exist"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -143,9 +265,15 @@ flock 9
|
|||||||
rm -rf "$NEXT_THEME_PATH"
|
rm -rf "$NEXT_THEME_PATH"
|
||||||
mkdir -p "$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 "$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
|
# 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
|
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
|
# omarchy:summary=Update user-installed git themes
|
||||||
|
|
||||||
for dir in ~/.config/omarchy/themes/*/; do
|
mapfile -t themes < <(omarchy-theme-extras)
|
||||||
if [[ -d $dir ]] && [[ ! -L ${dir%/} ]] && [[ -d $dir/.git ]]; then
|
|
||||||
echo "Updating: $(basename "$dir")"
|
for theme in "${themes[@]}"; do
|
||||||
git -C "$dir" pull
|
echo "Updating: $(basename "$theme")"
|
||||||
fi
|
git -C "$theme" pull
|
||||||
done
|
done
|
||||||
@@ -7,44 +7,70 @@
|
|||||||
KIND="${1:-}"
|
KIND="${1:-}"
|
||||||
ACTION="${2:-toggle}"
|
ACTION="${2:-toggle}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: omarchy-toggle-input-device <touchpad|touchscreen> [on|off|toggle]" >&2
|
||||||
|
}
|
||||||
|
|
||||||
case "$KIND" in
|
case "$KIND" in
|
||||||
touchpad) LABEL="Touchpad" ICON="touchpad" ;;
|
touchpad) LABEL="Touchpad" ICON="touchpad" ;;
|
||||||
touchscreen) LABEL="Touchscreen" ICON="touch" ;;
|
touchscreen) LABEL="Touchscreen" ICON="touch" ;;
|
||||||
*)
|
*)
|
||||||
echo "Usage: omarchy-toggle-input-device <touchpad|touchscreen> [on|off|toggle]" >&2
|
usage
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Hyprland sources this directory on reload, so the disabled state survives restarts
|
# The persisted disable is the device name stored as plain data; on every
|
||||||
STATE_FILE="$HOME/.local/state/omarchy/toggles/hypr/$KIND-disabled.lua"
|
# 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")"
|
device="$("omarchy-hw-$KIND")"
|
||||||
|
|
||||||
if [[ -z $device ]]; then
|
require_device() {
|
||||||
echo "No $KIND device found" >&2
|
if [[ -z $device ]]; then
|
||||||
exit 1
|
echo "No $KIND device found" >&2
|
||||||
fi
|
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() {
|
enable() {
|
||||||
hyprctl eval "hl.device({ name = \"$device\", enabled = true })" >/dev/null
|
# Clear the persisted state before requiring a usable device, so a device
|
||||||
rm -f "$STATE_FILE"
|
# 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"
|
omarchy-osd -i "$ICON" -m "$LABEL enabled"
|
||||||
}
|
}
|
||||||
|
|
||||||
disable() {
|
disable() {
|
||||||
hyprctl eval "hl.device({ name = \"$device\", enabled = false })" >/dev/null
|
require_device
|
||||||
mkdir -p "$(dirname "$STATE_FILE")"
|
apply_device false
|
||||||
printf 'hl.device({ name = "%s", enabled = false })\n' "$device" >"$STATE_FILE"
|
mkdir -p "$(dirname "$NAME_FILE")"
|
||||||
|
printf '%s\n' "$device" >"$NAME_FILE"
|
||||||
omarchy-osd -i "$ICON" -m "$LABEL disabled"
|
omarchy-osd -i "$ICON" -m "$LABEL disabled"
|
||||||
}
|
}
|
||||||
|
|
||||||
case "$ACTION" in
|
case "$ACTION" in
|
||||||
on) enable ;;
|
on) enable ;;
|
||||||
off) disable ;;
|
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
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
@@ -1317,9 +1317,9 @@ apply_system_transition() {
|
|||||||
printf '%s\n' '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \
|
printf '%s\n' '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | \
|
||||||
as_root tee /usr/lib/chromium/initial_preferences >/dev/null
|
as_root tee /usr/lib/chromium/initial_preferences >/dev/null
|
||||||
|
|
||||||
if getent group docker >/dev/null; then
|
# Deliberately do NOT add the user to the docker group. That group is
|
||||||
as_root usermod -aG docker "$target_user"
|
# root-equivalent, so it is opt-in now (Setup > Security > Sudoless Docker);
|
||||||
fi
|
# re-granting it on upgrade would silently undo that default for everyone.
|
||||||
|
|
||||||
if [[ -f /usr/bin/powerprofilesctl ]]; then
|
if [[ -f /usr/bin/powerprofilesctl ]]; then
|
||||||
as_root sed -i '/env python3/ c\#!/bin/python3' /usr/bin/powerprofilesctl || true
|
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:args=<install|remove|launch|stop|status> [options]
|
||||||
# omarchy:requires-sudo=true
|
# 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() {
|
check_prerequisites() {
|
||||||
local DISK_SIZE_GB=${1:-64}
|
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
|
# Check for KVM support
|
||||||
if [[ ! -e /dev/kvm ]]; then
|
if [[ ! -e /dev/kvm ]]; then
|
||||||
@@ -26,7 +386,7 @@ check_prerequisites() {
|
|||||||
|
|
||||||
# Check disk space
|
# Check disk space
|
||||||
AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}')
|
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 "❌ Insufficient disk space!"
|
||||||
echo " Available: ${AVAILABLE_SPACE}GB"
|
echo " Available: ${AVAILABLE_SPACE}GB"
|
||||||
echo " Required: ${REQUIRED_SPACE}GB (${DISK_SIZE_GB}GB disk + 10GB for Windows image)"
|
echo " Required: ${REQUIRED_SPACE}GB (${DISK_SIZE_GB}GB disk + 10GB for Windows image)"
|
||||||
@@ -34,6 +394,8 @@ check_prerequisites() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- commands ----------------------------------------------------------------
|
||||||
|
|
||||||
install_windows() {
|
install_windows() {
|
||||||
# Set up trap to handle Ctrl+C
|
# Set up trap to handle Ctrl+C
|
||||||
trap "echo ''; echo 'Installation cancelled by user'; exit 1" INT
|
trap "echo ''; echo 'Installation cancelled by user'; exit 1" INT
|
||||||
@@ -43,10 +405,9 @@ install_windows() {
|
|||||||
omarchy-pkg-add freerdp openbsd-netcat gum
|
omarchy-pkg-add freerdp openbsd-netcat gum
|
||||||
|
|
||||||
mkdir -p "$HOME/.windows"
|
mkdir -p "$HOME/.windows"
|
||||||
mkdir -p "$HOME/.config/windows"
|
|
||||||
mkdir -p "$HOME/.local/share/applications"
|
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]
|
[Desktop Entry]
|
||||||
Name=Windows
|
Name=Windows
|
||||||
Comment=Start Windows VM via Docker and connect with RDP
|
Comment=Start Windows VM via Docker and connect with RDP
|
||||||
@@ -70,7 +431,7 @@ EOF
|
|||||||
|
|
||||||
RAM_OPTIONS=""
|
RAM_OPTIONS=""
|
||||||
for size in 2 4 8 16 32 64; do
|
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"
|
RAM_OPTIONS="$RAM_OPTIONS ${size}G"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -91,16 +452,16 @@ EOF
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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"
|
echo "Invalid input. Using default: 2 cores"
|
||||||
SELECTED_CORES=2
|
SELECTED_CORES=2
|
||||||
fi
|
fi
|
||||||
|
|
||||||
AVAILABLE_SPACE=$(df "$HOME" | awk 'NR==2 {print int($4/1024/1024)}')
|
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
|
# 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 "❌ Insufficient disk space for Windows VM!"
|
||||||
echo " Available: ${AVAILABLE_SPACE}GB"
|
echo " Available: ${AVAILABLE_SPACE}GB"
|
||||||
echo " Minimum required: 42GB (32GB disk + 10GB for Windows image)"
|
echo " Minimum required: 42GB (32GB disk + 10GB for Windows image)"
|
||||||
@@ -109,7 +470,7 @@ EOF
|
|||||||
|
|
||||||
DISK_OPTIONS=""
|
DISK_OPTIONS=""
|
||||||
for size in 32 64 128 256 512; do
|
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"
|
DISK_OPTIONS="$DISK_OPTIONS ${size}G"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -139,6 +500,10 @@ EOF
|
|||||||
if [[ -z $USERNAME ]]; then
|
if [[ -z $USERNAME ]]; then
|
||||||
USERNAME="docker"
|
USERNAME="docker"
|
||||||
fi
|
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:")
|
PASSWORD=$(gum input --placeholder="Password (Press enter to use default: admin)" --password --header="Enter Windows password:")
|
||||||
if [[ -z $PASSWORD ]]; then
|
if [[ -z $PASSWORD ]]; then
|
||||||
@@ -147,6 +512,11 @@ EOF
|
|||||||
else
|
else
|
||||||
PASSWORD_DISPLAY="(user-defined)"
|
PASSWORD_DISPLAY="(user-defined)"
|
||||||
fi
|
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
|
# Display configuration summary
|
||||||
gum style \
|
gum style \
|
||||||
@@ -170,38 +540,19 @@ EOF
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p $HOME/Windows
|
mkdir -p "$HOME/Windows"
|
||||||
|
|
||||||
# Create docker-compose.yml in user config directory
|
local tz
|
||||||
cat << EOF | tee "$COMPOSE_FILE" > /dev/null
|
tz=$(timedatectl show -p Timezone --value 2>/dev/null || echo UTC)
|
||||||
services:
|
|
||||||
windows:
|
# Write the root-owned compose from the validated settings (one prompt if
|
||||||
image: dockurr/windows
|
# sudoless Docker is off), then bring the stack up.
|
||||||
container_name: omarchy-windows
|
write_compose "$SELECTED_RAM" "$SELECTED_CORES" "$SELECTED_DISK" \
|
||||||
environment:
|
"$USERNAME" "$PASSWORD" "$tz" "$HOME/.windows" "$HOME/Windows" || {
|
||||||
VERSION: "11"
|
echo "❌ Failed to write the Windows VM configuration."
|
||||||
RAM_SIZE: "$SELECTED_RAM"
|
exit 1
|
||||||
CPU_CORES: "$SELECTED_CORES"
|
}
|
||||||
DISK_SIZE: "$SELECTED_DISK"
|
write_credentials "$USERNAME" "$PASSWORD"
|
||||||
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
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Starting Windows VM installation..."
|
echo "Starting Windows VM installation..."
|
||||||
@@ -210,14 +561,12 @@ EOF
|
|||||||
echo "Monitor installation progress at: http://127.0.0.1:8006"
|
echo "Monitor installation progress at: http://127.0.0.1:8006"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# Start docker-compose with user's config
|
|
||||||
echo "Starting Windows VM with docker-compose..."
|
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 "❌ Failed to start Windows VM!"
|
||||||
echo " Common issues:"
|
echo " Common issues:"
|
||||||
echo " - Docker daemon not running: sudo systemctl start docker"
|
echo " - Docker daemon not running: sudo systemctl start docker"
|
||||||
echo " - Port already in use: check if another VM is running"
|
echo " - Port already in use: check if another VM is running"
|
||||||
echo " - Permission issues: make sure you're in the docker group"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -237,7 +586,6 @@ EOF
|
|||||||
echo "Once finished, launch 'Windows' via Super + Space"
|
echo "Once finished, launch 'Windows' via Super + Space"
|
||||||
echo ""
|
echo ""
|
||||||
echo "To stop the VM: omarchy-windows-vm stop"
|
echo "To stop the VM: omarchy-windows-vm stop"
|
||||||
echo "To change resources: ~/.config/windows/docker-compose.yml"
|
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,11 +597,12 @@ remove_windows() {
|
|||||||
|
|
||||||
echo "Removing Windows VM..."
|
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 -f "$HOME/.local/share/applications/windows-vm.desktop"
|
||||||
|
|
||||||
rm "$HOME/.local/share/applications/windows-vm.desktop"
|
|
||||||
rm -rf "$HOME/.config/windows"
|
rm -rf "$HOME/.config/windows"
|
||||||
rm -rf "$HOME/.windows"
|
rm -rf "$HOME/.windows"
|
||||||
|
|
||||||
@@ -267,49 +616,30 @@ launch_windows() {
|
|||||||
KEEP_ALIVE=true
|
KEEP_ALIVE=true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Check if config exists
|
if ! migrate_legacy_compose; then
|
||||||
if [[ ! -f $COMPOSE_FILE ]]; then
|
if [[ ! -f $COMPOSE_FILE ]]; then
|
||||||
echo "Windows VM not configured. Please run: omarchy-windows-vm install"
|
echo "Windows VM not configured. Please run: omarchy-windows-vm install"
|
||||||
exit 1
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Extract credentials from compose file
|
# RDP credentials come from the private per-user file. Fall back to the compose
|
||||||
WIN_USER=$(grep "USERNAME:" "$COMPOSE_FILE" | sed 's/.*USERNAME: "\(.*\)"/\1/')
|
# only when it is readable (sudoless mode), reversing the writer's escaping.
|
||||||
WIN_PASS=$(grep "PASSWORD:" "$COMPOSE_FILE" | sed 's/.*PASSWORD: "\(.*\)"/\1/')
|
WIN_USER=$(read_credential USERNAME) || WIN_USER=""
|
||||||
|
WIN_PASS=$(read_credential PASSWORD) || WIN_PASS=""
|
||||||
# Use defaults if not found
|
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_USER ]] && WIN_USER="docker"
|
||||||
[[ -z $WIN_PASS ]] && WIN_PASS="admin"
|
[[ -z $WIN_PASS ]] && WIN_PASS="admin"
|
||||||
|
|
||||||
# Check if container is already running
|
echo "Starting Windows VM (this may prompt for authorization)..."
|
||||||
CONTAINER_STATUS=$(docker inspect --format='{{.State.Status}}' omarchy-windows 2>/dev/null)
|
if ! priv up_wait; then
|
||||||
|
echo "❌ Failed to start Windows VM!"
|
||||||
if [[ $CONTAINER_STATUS != "running" ]]; then
|
echo " Try checking: omarchy-windows-vm status"
|
||||||
echo "Starting Windows VM..."
|
omarchy-notification-send -u critical "Windows VM" "Failed to start Windows VM"
|
||||||
|
exit 1
|
||||||
# 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
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build the connection info
|
# Build the connection info
|
||||||
@@ -329,14 +659,27 @@ To stop: omarchy-windows-vm stop"
|
|||||||
"" \
|
"" \
|
||||||
"$LIFECYCLE"
|
"$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
|
# Detect display scale from Hyprland
|
||||||
HYPR_SCALE=$(hyprctl monitors -j | jq -r '.[] | select (.focused == true) | .scale')
|
HYPR_SCALE=$(hyprctl monitors -j | jq -r '.[] | select (.focused == true) | .scale')
|
||||||
SCALE_PERCENT=$(echo "$HYPR_SCALE" | awk '{print int($1 * 100)}')
|
SCALE_PERCENT=$(echo "$HYPR_SCALE" | awk '{print int($1 * 100)}')
|
||||||
|
|
||||||
RDP_SCALE=""
|
RDP_SCALE=""
|
||||||
if (( SCALE_PERCENT >= 170 )); then
|
if ((SCALE_PERCENT >= 170)); then
|
||||||
RDP_SCALE="/scale:180"
|
RDP_SCALE="/scale:180"
|
||||||
elif (( SCALE_PERCENT >= 130 )); then
|
elif ((SCALE_PERCENT >= 130)); then
|
||||||
RDP_SCALE="/scale:140"
|
RDP_SCALE="/scale:140"
|
||||||
fi
|
fi
|
||||||
# If scale is less than 130%, don't set any scale (use default 100)
|
# 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
|
if [[ $KEEP_ALIVE = "false" ]]; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "RDP session closed. Stopping Windows VM..."
|
echo "RDP session closed. Stopping Windows VM..."
|
||||||
docker-compose -f "$COMPOSE_FILE" down
|
if priv down; then
|
||||||
echo "Windows VM stopped."
|
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
|
else
|
||||||
echo ""
|
echo ""
|
||||||
echo "RDP session closed. Windows VM is still running."
|
echo "RDP session closed. Windows VM is still running."
|
||||||
@@ -358,24 +705,34 @@ To stop: omarchy-windows-vm stop"
|
|||||||
}
|
}
|
||||||
|
|
||||||
stop_windows() {
|
stop_windows() {
|
||||||
|
migrate_legacy_compose 2>/dev/null || true
|
||||||
if [[ ! -f $COMPOSE_FILE ]]; then
|
if [[ ! -f $COMPOSE_FILE ]]; then
|
||||||
echo "Windows VM not configured."
|
echo "Windows VM not configured."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Stopping Windows VM..."
|
echo "Stopping Windows VM..."
|
||||||
docker-compose -f "$COMPOSE_FILE" down
|
if priv down; then
|
||||||
echo "Windows VM stopped."
|
echo "Windows VM stopped."
|
||||||
|
else
|
||||||
|
echo "⚠️ Could not stop the Windows VM (authorization declined?). It may still be running."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
status_windows() {
|
status_windows() {
|
||||||
|
migrate_legacy_compose 2>/dev/null || true
|
||||||
if [[ ! -f $COMPOSE_FILE ]]; then
|
if [[ ! -f $COMPOSE_FILE ]]; then
|
||||||
echo "Windows VM not configured."
|
echo "Windows VM not configured."
|
||||||
echo "To set up: omarchy-windows-vm install"
|
echo "To set up: omarchy-windows-vm install"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
if [[ -z $CONTAINER_STATUS ]]; then
|
||||||
echo "Windows VM container not found."
|
echo "Windows VM container not found."
|
||||||
@@ -421,28 +778,41 @@ show_usage() {
|
|||||||
|
|
||||||
# Main command dispatcher
|
# Main command dispatcher
|
||||||
case "$1" in
|
case "$1" in
|
||||||
install)
|
__priv)
|
||||||
install_windows
|
((EUID == 0)) || {
|
||||||
;;
|
echo "omarchy-windows-vm __priv must run as root" >&2
|
||||||
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
|
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
|
esac
|
||||||
@@ -4,4 +4,4 @@
|
|||||||
# To put it into use, remove .sample from this file name.
|
# To put it into use, remove .sample from this file name.
|
||||||
|
|
||||||
# Example: Show the name of the font that was just set.
|
# 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
|
weather=$(omarchy-weather-status 2>/dev/null) || true
|
||||||
|
|
||||||
if [[ -n $weather && $weather != "Weather unavailable" ]]; then
|
if [[ -n $weather && $weather != "Weather unavailable" ]]; then
|
||||||
notify-send -u low "$weather"
|
omarchy-notification-send -u low "$weather"
|
||||||
fi
|
fi
|
||||||
@@ -4,4 +4,4 @@
|
|||||||
# To put it into use, remove .sample from this file name.
|
# To put it into use, remove .sample from this file name.
|
||||||
|
|
||||||
# Example: Show notification after the system has been updated.
|
# 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.
|
# To put it into use, remove .sample from this file name.
|
||||||
|
|
||||||
# Example: Show the name of the theme that was just set.
|
# 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
|
Additional user backgrounds for any theme (stock or custom) go in
|
||||||
`~/.config/omarchy/backgrounds/<theme-slug>/`.
|
`~/.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
|
## Customizing a Stock Theme
|
||||||
|
|
||||||
Never edit stock themes under `/usr/share/omarchy/themes/` — changes are lost
|
Never edit stock themes under `/usr/share/omarchy/themes/` — changes are lost
|
||||||
on update. Two safe options:
|
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
|
**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
|
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:
|
applied, the stock theme is copied first and your files win on top:
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ alias ....='cd ../../..'
|
|||||||
# Tools
|
# Tools
|
||||||
alias a='omarchy-agent --inline'
|
alias a='omarchy-agent --inline'
|
||||||
alias c='opencode --auto'
|
alias c='opencode --auto'
|
||||||
alias cx='printf "\033[2J\033[3J\033[H" && claude --permission-mode bypassPermissions'
|
alias cx='printf "\033[2J\033[3J\033[H" && claude --permission-mode auto'
|
||||||
alias cy='codex -s danger-full-access -a never'
|
alias cy='codex --approve-for-me'
|
||||||
alias d='docker'
|
alias d='docker'
|
||||||
alias r='rails'
|
alias r='rails'
|
||||||
alias t='tmux attach || tmux new -s Work'
|
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 + CTRL + RETURN", "Herdr", { omarchy = "terminal-herdr" })
|
||||||
o.bind("SUPER + SHIFT + M", "Music", { omarchy = "spotify" })
|
o.bind("SUPER + SHIFT + M", "Music", { omarchy = "spotify" })
|
||||||
o.bind("SUPER + SHIFT + ALT + M", "Music TUI", { tui = "cliamp", focus = true })
|
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 + G", "Signal", { omarchy = "signal" })
|
||||||
o.bind("SUPER + SHIFT + O", "Obsidian", { launch = "obsidian", focus = "^obsidian$" })
|
o.bind("SUPER + SHIFT + O", "Obsidian", { launch = "obsidian", focus = "^obsidian$" })
|
||||||
o.bind("SUPER + SHIFT + W", "Omawrite", { launch = "omawrite" })
|
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
|
return false
|
||||||
end
|
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)
|
function o.shell_succeeds(command)
|
||||||
local ok, _, code = os.execute(command .. " >/dev/null 2>&1")
|
-- Subshell, so the redirection covers every command rather than binding to
|
||||||
return ok == true or ok == 0 or code == 0
|
-- 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
|
end
|
||||||
|
|
||||||
function o.cmd_present(command)
|
function o.cmd_present(command)
|
||||||
@@ -125,7 +136,7 @@ function o.bind_toggle(keys, description, toggle, options)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function o.notify(message)
|
function o.notify(message)
|
||||||
return "notify-send -u low " .. shell_quote(message)
|
return "omarchy-notification-send -u low " .. shell_quote(message)
|
||||||
end
|
end
|
||||||
|
|
||||||
function o.window(match, rules)
|
function o.window(match, rules)
|
||||||
|
|||||||
+13
-3
@@ -4,9 +4,19 @@
|
|||||||
|
|
||||||
local home = os.getenv("HOME")
|
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 {
|
return {
|
||||||
home = home,
|
home = home,
|
||||||
config_home = os.getenv("XDG_CONFIG_HOME") or (home .. "/.config"),
|
config_home = env_or("XDG_CONFIG_HOME", home .. "/.config"),
|
||||||
state_home = os.getenv("XDG_STATE_HOME") or (home .. "/.local/state"),
|
state_home = env_or("XDG_STATE_HOME", home .. "/.local/state"),
|
||||||
omarchy_path = os.getenv("OMARCHY_PATH") or "/usr/share/omarchy",
|
omarchy_path = env_or("OMARCHY_PATH", "/usr/share/omarchy"),
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
-- Pass a module prefix for normal package.path modules, e.g.
|
-- Pass a module prefix for normal package.path modules, e.g.
|
||||||
-- require_all.files(paths.omarchy_path .. "/default/hypr/apps", "default.hypr.apps")
|
-- 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 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 = {}
|
local M = {}
|
||||||
|
|
||||||
@@ -12,19 +14,23 @@ local function shell_quote(path)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function M.files(dir, module_prefix, options)
|
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")
|
local handle = io.popen("find " .. shell_quote(dir) .. " -maxdepth 1 -type f -name '*.lua' -printf '%f\\n' 2>/dev/null | sort")
|
||||||
if handle then
|
if handle then
|
||||||
for filename in handle:lines() do
|
for filename in handle:lines() do
|
||||||
local module = filename:gsub("%.lua$", "")
|
local name = filename:gsub("%.lua$", "")
|
||||||
if module_prefix then
|
if not exclude[name] then
|
||||||
module = module_prefix .. "." .. module
|
local module = name
|
||||||
end
|
if module_prefix then
|
||||||
|
module = module_prefix .. "." .. module
|
||||||
|
end
|
||||||
|
|
||||||
if options and options.reload then
|
if options and options.reload then
|
||||||
package.loaded[module] = nil
|
package.loaded[module] = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
require(module)
|
require(module)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
handle:close()
|
handle:close()
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,6 +4,20 @@ local require_all = require("default.hypr.require_all")
|
|||||||
local toggles_dir = paths.state_home .. "/omarchy/toggles/hypr"
|
local toggles_dir = paths.state_home .. "/omarchy/toggles/hypr"
|
||||||
package.path = toggles_dir .. "/?.lua;" .. package.path
|
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")
|
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.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.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.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.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.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"},
|
"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.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.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.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.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.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'"},
|
"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.omarchy": {"icon":"","iconFont":"omarchy","label":"Omarchy","action":"omarchy-launch-floating-terminal-with-presentation omarchy-update"},
|
||||||
"update.channel": {"icon":"","label":"Channel"},
|
"update.channel": {"icon":"","label":"Channel"},
|
||||||
"update.config": {"icon":"","label":"Config","title":"Reset to default"},
|
"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.process": {"icon":"","label":"Process","title":"Restart"},
|
||||||
"update.hardware": {"icon":"","label":"Hardware","title":"Restart"},
|
"update.hardware": {"icon":"","label":"Hardware","title":"Restart"},
|
||||||
"update.firmware": {"icon":"","label":"Firmware","action":"omarchy-launch-floating-terminal-with-presentation omarchy-update-firmware"},
|
"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
|
`colors.toml`; Omarchy generates the active theme files from
|
||||||
`default/themed/*.tpl` when `omarchy-theme-set <name>` runs.
|
`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
|
## Theme activation flow
|
||||||
|
|
||||||
`omarchy-theme-set <name>` builds a clean staging directory at
|
`omarchy-theme-set <name>` builds a clean staging directory at
|
||||||
`~/.local/state/omarchy/current/next-theme`:
|
`~/.local/state/omarchy/current/next-theme`:
|
||||||
|
|
||||||
1. Copy the first-party theme from `themes/<name>/`.
|
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`.
|
3. If needed, generate `colors.toml` from `alacritty.toml`.
|
||||||
4. Run `omarchy-theme-set-templates` to render templates into the staging
|
4. Run `omarchy-theme-set-templates` to render templates into the staging
|
||||||
theme.
|
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 templates. If a user template has the same output filename as a
|
||||||
built-in template, the built-in output is skipped.
|
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`
|
||||||
|
|
||||||
`colors.toml` provides the palette keys used by templates. Keys are grouped
|
`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
|
## Adding or overriding theme files
|
||||||
|
|
||||||
- Add palette values to `themes/<name>/colors.toml`.
|
- 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.
|
- 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
|
- Add a hand-written file in `themes/<name>/` only when that theme needs to
|
||||||
override the generated output entirely.
|
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,
|
# The Docker daemon runs as root and its socket is root-owned, so membership in
|
||||||
# then grant it directly when the install user already exists (deferred-provisioning
|
# the docker group is equivalent to passwordless root: any process in it can
|
||||||
# installs create the user at first boot instead).
|
# `docker run -v /:/host` and rewrite the host as root. We therefore do NOT add
|
||||||
provisioning_dir="${OMARCHY_PROVISIONING_DIR:-/var/lib/omarchy/provisioning}"
|
# the install user to the docker group by default, so a single rogue process
|
||||||
mkdir -p "$provisioning_dir"
|
# running as the user cannot silently escalate to root.
|
||||||
grep -qxF docker "$provisioning_dir/groups" 2>/dev/null || echo docker >>"$provisioning_dir/groups"
|
#
|
||||||
|
# The daemon is still enabled (docker.socket, in enable-services.sh) for system
|
||||||
if [[ -n ${OMARCHY_INSTALL_USER:-} ]] && getent passwd "$OMARCHY_INSTALL_USER" >/dev/null; then
|
# use. The Docker TUI (Super + Shift + D) and the Windows VM reach it through a
|
||||||
usermod -aG docker "$OMARCHY_INSTALL_USER"
|
# polkit prompt, and the plain `docker` CLI runs under sudo. Users who want the
|
||||||
fi
|
# 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
|
# Enable Synaptics InterTouch for confirmed touchpads if not already loaded
|
||||||
|
#
|
||||||
if grep -qi synaptics /proc/bus/input/devices \
|
# Only when modprobe can resolve psmouse for the running kernel, and never
|
||||||
&& ! lsmod | grep -q '^psmouse'; then
|
# fatally. Installs run this under arch-chroot, where uname -r still names the
|
||||||
modprobe psmouse synaptics_intertouch=1
|
# 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
|
fi
|
||||||
@@ -78,7 +78,7 @@ lua51
|
|||||||
luarocks
|
luarocks
|
||||||
man-db
|
man-db
|
||||||
mariadb-libs
|
mariadb-libs
|
||||||
mise
|
mise-bin
|
||||||
moonlight-qt
|
moonlight-qt
|
||||||
mpv
|
mpv
|
||||||
mpv-mpris
|
mpv-mpris
|
||||||
@@ -108,7 +108,8 @@ python-poetry-core
|
|||||||
ttfx
|
ttfx
|
||||||
qemu-user-static-binfmt
|
qemu-user-static-binfmt
|
||||||
qrencode
|
qrencode
|
||||||
quickshell-git
|
qt6-imageformats
|
||||||
|
quickshell
|
||||||
ripgrep
|
ripgrep
|
||||||
ruby
|
ruby
|
||||||
tensaku
|
tensaku
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ set -e
|
|||||||
if omarchy-done ensure voxtype-install-invitation; then
|
if omarchy-done ensure voxtype-install-invitation; then
|
||||||
omarchy-notification-send -u critical -g "Install Dictation with Voxtype" \
|
omarchy-notification-send -u critical -g "Install Dictation with Voxtype" \
|
||||||
"Click to install voice dictation for Omarchy." \
|
"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
|
fi
|
||||||
@@ -7,5 +7,5 @@ set -e
|
|||||||
if [[ -z $(omarchy-default-agent) ]] && omarchy-done ensure agent-setup-invitation; then
|
if [[ -z $(omarchy-default-agent) ]] && omarchy-done ensure agent-setup-invitation; then
|
||||||
omarchy-notification-send -u critical -g "Set your default agent" \
|
omarchy-notification-send -u critical -g "Set your default agent" \
|
||||||
"Let your favorite agent help with Omarchy." \
|
"Let your favorite agent help with Omarchy." \
|
||||||
--exec "omarchy menu summon setup.default.agent"
|
--exec omarchy menu summon setup.default.agent
|
||||||
fi
|
fi
|
||||||
@@ -8,5 +8,5 @@ if omarchy-hw-fingerprint && [[ ! -f /etc/pam.d/omarchy-lock-fingerprint ]] &&
|
|||||||
omarchy-done ensure fingerprint-setup-invitation; then
|
omarchy-done ensure fingerprint-setup-invitation; then
|
||||||
omarchy-notification-send -u critical -g "Setup Fingerprint Reader" \
|
omarchy-notification-send -u critical -g "Setup Fingerprint Reader" \
|
||||||
"Enable sudo and unlocking with your fingerprint." \
|
"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
|
fi
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
notify_update() {
|
notify_update() {
|
||||||
omarchy-notification-send -u critical -g "Update System" "Click to update the system." \
|
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() {
|
notify_wifi() {
|
||||||
omarchy-notification-send -u critical -g "Setup Wi-Fi" "Click to configure the wireless network." \
|
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() {
|
announce_network() {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ From the terminal, the same switches are `omarchy toggle <thing>`. Run `omarchy
|
|||||||
| Suspend | — | `omarchy toggle suspend` |
|
| Suspend | — | `omarchy toggle suspend` |
|
||||||
| Hybrid GPU | — | `omarchy toggle hybrid gpu` |
|
| 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`).
|
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
|
||||||
|
|
||||||
[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.
|
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.
|
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.
|
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_.
|
[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.
|
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`.
|
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
|
### 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.)
|
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.
|
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).
|
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 github:can1357/oh-my-pi omp
|
||||||
omarchy-mise-install npm:@xai-official/grok grok
|
omarchy-mise-install npm:@xai-official/grok grok
|
||||||
omarchy-mise-install crush
|
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"
|
rm -f "$HOME/.local/bin/omp"
|
||||||
fi
|
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])
|
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) {
|
function isPlainObject(value) {
|
||||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -369,7 +369,13 @@ PanelWindow {
|
|||||||
|
|
||||||
Text {
|
Text {
|
||||||
anchors.horizontalCenter: parent.horizontalCenter
|
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
|
color: root.onScrim
|
||||||
font.family: root.fontFamily
|
font.family: root.fontFamily
|
||||||
font.pixelSize: Style.font.display
|
font.pixelSize: Style.font.display
|
||||||
|
|||||||
@@ -1417,6 +1417,9 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onPressAndHold: function(mouse) {
|
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)
|
startDrag(mouse.x, mouse.y)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,50 @@ emit_image() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
emit_text() {
|
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
|
case "${1:-}" in
|
||||||
|
|||||||
@@ -57,14 +57,39 @@ function glyphFromHints(hints) {
|
|||||||
return stringHint(hints, "omarchy-glyph")
|
return stringHint(hints, "omarchy-glyph")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shell command to run when the card is clicked, sent by
|
// The click action: a JSON argv string from omarchy-notification-send
|
||||||
// omarchy-notification-send --exec. Carrying the action as data means it
|
// --exec. Carried as data so a toast restored after a shell restart stays
|
||||||
// travels with the popup through the persistence files, so a toast restored
|
// clickable (a libnotify action can't — its sender is gone). Run via
|
||||||
// after a shell restart clicks through exactly like a live one. A libnotify
|
// Util.execArgv as bash positional parameters, never a shell string, so
|
||||||
// action can't: its sender is still waiting on an id from a server generation
|
// attacker-controlled values (a title, a filename) can't become commands.
|
||||||
// that no longer exists.
|
function execArgvFromHints(hints) {
|
||||||
function execFromHints(hints) {
|
return stringHint(hints, "omarchy-exec-argv")
|
||||||
return stringHint(hints, "omarchy-exec")
|
}
|
||||||
|
|
||||||
|
// 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) {
|
function shouldRenderCompactGlyph(glyph, iconSource, singleLineToast) {
|
||||||
@@ -85,7 +110,7 @@ function snapshotOf(notification, timestamp) {
|
|||||||
body: n.body || "",
|
body: n.body || "",
|
||||||
image: n.image || "",
|
image: n.image || "",
|
||||||
glyph: glyphFromHints(n.hints),
|
glyph: glyphFromHints(n.hints),
|
||||||
exec: execFromHints(n.hints),
|
execArgv: execArgvFromHints(n.hints),
|
||||||
urgency: n.urgency,
|
urgency: n.urgency,
|
||||||
expireTimeout: expireTimeout,
|
expireTimeout: expireTimeout,
|
||||||
timestamp: timestamp === undefined ? Date.now() : timestamp
|
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
|
// Everything the popup card draws, and therefore everything an in-place
|
||||||
// update has to write through to the row and its file.
|
// 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() {
|
function popupRoles() {
|
||||||
return POPUP_ROLES
|
return POPUP_ROLES
|
||||||
@@ -136,7 +161,7 @@ function historyEntry(value, normalUrgency) {
|
|||||||
body: e.body || "",
|
body: e.body || "",
|
||||||
image: e.image || "",
|
image: e.image || "",
|
||||||
glyph: e.glyph || "",
|
glyph: e.glyph || "",
|
||||||
exec: e.exec || "",
|
execArgv: e.execArgv || "",
|
||||||
urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency,
|
urgency: typeof e.urgency === "number" ? e.urgency : normalUrgency,
|
||||||
expireTimeout: 0,
|
expireTimeout: 0,
|
||||||
timestamp: e.timestamp || 0
|
timestamp: e.timestamp || 0
|
||||||
@@ -346,7 +371,8 @@ if (typeof module !== "undefined") {
|
|||||||
isEphemeralApp: isEphemeralApp,
|
isEphemeralApp: isEphemeralApp,
|
||||||
stringHint: stringHint,
|
stringHint: stringHint,
|
||||||
glyphFromHints: glyphFromHints,
|
glyphFromHints: glyphFromHints,
|
||||||
execFromHints: execFromHints,
|
execArgvFromHints: execArgvFromHints,
|
||||||
|
parseExecArgv: parseExecArgv,
|
||||||
shouldRenderCompactGlyph: shouldRenderCompactGlyph,
|
shouldRenderCompactGlyph: shouldRenderCompactGlyph,
|
||||||
snapshotOf: snapshotOf,
|
snapshotOf: snapshotOf,
|
||||||
popupRoles: popupRoles,
|
popupRoles: popupRoles,
|
||||||
|
|||||||
@@ -353,18 +353,19 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Run the popup's click action, then dismiss. Omarchy's own toasts carry the
|
// 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
|
// action as an argv vector in the `execArgv` role (see execArgvFromHints),
|
||||||
// persistence files preserve, so restored toasts stay clickable. Third-party
|
// which the persistence files preserve, so restored toasts stay clickable.
|
||||||
// clients register a libnotify action under the canonical identifier
|
// Third-party clients register a libnotify action under the canonical
|
||||||
// "default" instead; that one only works while the sender is still live.
|
// identifier "default" instead; that one only works while the sender is live.
|
||||||
function invokePopupDefault(index) {
|
function invokePopupDefault(index) {
|
||||||
if (index < 0 || index >= popupModel.count) return
|
if (index < 0 || index >= popupModel.count) return
|
||||||
var entry = popupModel.get(index)
|
var entry = popupModel.get(index)
|
||||||
var command = entry ? String(entry.exec || "") : ""
|
|
||||||
if (command) {
|
// Run the argv (via Util.execArgv, no shell interpretation). Detached so it
|
||||||
// Detached so the launched command outlives the shell process, which the
|
// outlives the shell, which installer toasts depend on: they restart it.
|
||||||
// installer toasts depend on: they restart the shell as their first act.
|
var argv = NotificationLogic.parseExecArgv(entry ? entry.execArgv : "")
|
||||||
Util.execDetached(command)
|
if (argv) {
|
||||||
|
Util.execArgv(argv)
|
||||||
dismissPopup(index)
|
dismissPopup(index)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -665,7 +666,7 @@ Item {
|
|||||||
body: row.body,
|
body: row.body,
|
||||||
image: row.image,
|
image: row.image,
|
||||||
glyph: row.glyph || "",
|
glyph: row.glyph || "",
|
||||||
exec: row.exec || "",
|
execArgv: row.execArgv || "",
|
||||||
urgency: row.urgency,
|
urgency: row.urgency,
|
||||||
timestamp: row.timestamp
|
timestamp: row.timestamp
|
||||||
}, imagesDir).entry)
|
}, imagesDir).entry)
|
||||||
@@ -689,7 +690,7 @@ Item {
|
|||||||
body: "",
|
body: "",
|
||||||
image: "",
|
image: "",
|
||||||
glyph: "",
|
glyph: "",
|
||||||
exec: "",
|
execArgv: "",
|
||||||
urgency: NotificationUrgency.Low,
|
urgency: NotificationUrgency.Low,
|
||||||
expireTimeout: 0,
|
expireTimeout: 0,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
|
|||||||
@@ -64,7 +64,11 @@ Panel {
|
|||||||
// convention. Clicking the grid's "W" heading writes the choice back to
|
// convention. Clicking the grid's "W" heading writes the choice back to
|
||||||
// shell.json.
|
// shell.json.
|
||||||
readonly property int weekStart: Model.normalizedWeekStart(setting("weekStartDay", null), Qt.locale().firstDayOfWeek)
|
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 weekdays: Model.weekdayOrder(weekStart)
|
||||||
readonly property var weeks: Model.monthGrid(viewYear, viewMonth, weekStart, todayKey)
|
readonly property var weeks: Model.monthGrid(viewYear, viewMonth, weekStart, todayKey)
|
||||||
|
|
||||||
@@ -213,10 +217,9 @@ Panel {
|
|||||||
setWeekStart(Model.toggledWeekStart(root.weekStart))
|
setWeekStart(Model.toggledWeekStart(root.weekStart))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Locale short day names, trimmed of the trailing period some locales
|
// English short day names, matching the rest of the interface.
|
||||||
// carry ("man." -> "MAN") so the header row stays a clean band of caps.
|
|
||||||
function weekdayLabel(weekday) {
|
function weekdayLabel(weekday) {
|
||||||
return String(Qt.locale().dayName(weekday, Locale.ShortFormat)).replace(/\.$/, "").toUpperCase()
|
return String(labelLocale.dayName(weekday, Locale.ShortFormat)).toUpperCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
SystemClock {
|
SystemClock {
|
||||||
|
|||||||
@@ -299,8 +299,17 @@ function wifiSectionTitle(wifiNetworks, index) {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
function isProtected(security, openSecurity) {
|
// OWE (Enhanced Open) encrypts traffic without authenticating the user, so it
|
||||||
return security !== openSecurity
|
// 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
|
// The password arrives on stdin and reaches nmcli through the scriptable
|
||||||
@@ -316,10 +325,10 @@ var enterpriseConnectScript =
|
|||||||
" && nmcli connection up uuid \"$u\"" +
|
" && nmcli connection up uuid \"$u\"" +
|
||||||
" || { nmcli connection delete uuid \"$u\" >/dev/null 2>&1; false; }"
|
" || { nmcli connection delete uuid \"$u\" >/dev/null 2>&1; false; }"
|
||||||
|
|
||||||
function networkFailureReason(reason, reasons) {
|
function networkFailureReason(reason, needsCredentials, reasons) {
|
||||||
var r = reasons || {}
|
var r = reasons || {}
|
||||||
if (reason === r.NoSecrets) return "Passphrase required"
|
if (needsCredentials && reason === r.NoSecrets) return "Passphrase required"
|
||||||
if (reason === r.WifiAuthTimeout) return "Wrong password"
|
if (needsCredentials && reason === r.WifiAuthTimeout) return "Wrong password"
|
||||||
if (reason === r.WifiNetworkLost) return "Network lost"
|
if (reason === r.WifiNetworkLost) return "Network lost"
|
||||||
if (reason === r.WifiClientDisconnected) return "Disconnected"
|
if (reason === r.WifiClientDisconnected) return "Disconnected"
|
||||||
if (reason === r.WifiClientFailed) return "Connection failed"
|
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
|
// Whether a failed connect should reopen the passphrase prompt. NoSecrets
|
||||||
// always means credentials are missing. An auth timeout on a protected
|
// means credentials are missing only for a network that actually uses them.
|
||||||
// network means the saved passphrase is wrong (the same profile a first
|
// An auth timeout on such a network means the saved passphrase is wrong (the
|
||||||
// failed attempt leaves behind as "known"), so the user needs a chance to
|
// same profile a first failed attempt leaves behind as "known"), so the user
|
||||||
// re-enter it -- connectWithPsk overwrites the stored PSK on submit.
|
// needs a chance to re-enter it -- connectWithPsk overwrites the stored PSK on
|
||||||
function shouldRepromptPassphrase(reason, isProtected, reasons) {
|
// submit.
|
||||||
|
function shouldRepromptPassphrase(reason, needsCredentials, reasons) {
|
||||||
var r = reasons || {}
|
var r = reasons || {}
|
||||||
if (reason === r.NoSecrets) return true
|
if (!needsCredentials) return false
|
||||||
return !!isProtected && reason === r.WifiAuthTimeout
|
return reason === r.NoSecrets || reason === r.WifiAuthTimeout
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof module !== "undefined") {
|
if (typeof module !== "undefined") {
|
||||||
@@ -361,7 +371,8 @@ if (typeof module !== "undefined") {
|
|||||||
wifiRow: wifiRow,
|
wifiRow: wifiRow,
|
||||||
sortWifiRows: sortWifiRows,
|
sortWifiRows: sortWifiRows,
|
||||||
wifiSectionTitle: wifiSectionTitle,
|
wifiSectionTitle: wifiSectionTitle,
|
||||||
isProtected: isProtected,
|
requiresCredentials: requiresCredentials,
|
||||||
|
canForgetNetwork: canForgetNetwork,
|
||||||
enterpriseConnectScript: enterpriseConnectScript,
|
enterpriseConnectScript: enterpriseConnectScript,
|
||||||
networkFailureReason: networkFailureReason,
|
networkFailureReason: networkFailureReason,
|
||||||
shouldRepromptPassphrase: shouldRepromptPassphrase
|
shouldRepromptPassphrase: shouldRepromptPassphrase
|
||||||
|
|||||||
@@ -398,7 +398,7 @@ Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canForgetNetwork(net) {
|
function canForgetNetwork(net) {
|
||||||
return !!(net && net.known && isProtected(net.security) && !net.connected)
|
return Model.canForgetNetwork(net)
|
||||||
}
|
}
|
||||||
|
|
||||||
function canShareNetwork(net) {
|
function canShareNetwork(net) {
|
||||||
@@ -417,8 +417,8 @@ Panel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Enter/Space on the highlighted row. Mirrors row-click semantics:
|
// Enter/Space on the highlighted row. Mirrors row-click semantics:
|
||||||
// connected → disconnect, protected-unknown → password prompt,
|
// connected → disconnect, credentials-required/unknown → prompt,
|
||||||
// open/known → connect.
|
// passwordless/known → connect.
|
||||||
function activateSelected() {
|
function activateSelected() {
|
||||||
if (busy || selectedIndex < 0 || selectedIndex >= wifiNetworks.length) return
|
if (busy || selectedIndex < 0 || selectedIndex >= wifiNetworks.length) return
|
||||||
var net = wifiNetworks[selectedIndex]
|
var net = wifiNetworks[selectedIndex]
|
||||||
@@ -428,8 +428,8 @@ Panel {
|
|||||||
// connectedWifiNetwork when handed null, so a row left stale by scan churn
|
// connectedWifiNetwork when handed null, so a row left stale by scan churn
|
||||||
// would otherwise tear down whatever is connected now instead.
|
// would otherwise tear down whatever is connected now instead.
|
||||||
if (net.connected) { disconnectRow(net.ssid); return }
|
if (net.connected) { disconnectRow(net.ssid); return }
|
||||||
if (isProtected(net.security) && !net.known) { openPasswordPrompt(net.ssid); return }
|
if (requiresCredentials(net.security) && !net.known) { openPasswordPrompt(net.ssid); return }
|
||||||
connectKnown(net.ssid)
|
connectDirectly(net.ssid)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bar pill state, derived from the native NetworkManager service so the
|
// Bar pill state, derived from the native NetworkManager service so the
|
||||||
@@ -680,8 +680,8 @@ Panel {
|
|||||||
root.close()
|
root.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
function isProtected(security) {
|
function requiresCredentials(security) {
|
||||||
return Model.isProtected(security, WifiSecurityType.Open)
|
return Model.requiresCredentials(security, WifiSecurityType.Open, WifiSecurityType.Owe)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPasswordPrompt(ssid) {
|
function openPasswordPrompt(ssid) {
|
||||||
@@ -735,18 +735,18 @@ Panel {
|
|||||||
if (!network || actionKind === "" || actionSsid !== (network.name || "")) return
|
if (!network || actionKind === "" || actionSsid !== (network.name || "")) return
|
||||||
actionTimeout.stop()
|
actionTimeout.stop()
|
||||||
failureSsid = actionSsid
|
failureSsid = actionSsid
|
||||||
failureReason = networkFailureReason(reason)
|
failureReason = networkFailureReason(reason, requiresCredentials(network.security))
|
||||||
actionSsid = ""
|
actionSsid = ""
|
||||||
actionKind = ""
|
actionKind = ""
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
function networkFailureReason(reason) {
|
function networkFailureReason(reason, needsCredentials) {
|
||||||
return Model.networkFailureReason(reason, connectionFailReasons)
|
return Model.networkFailureReason(reason, needsCredentials, connectionFailReasons)
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldRepromptPassphrase(reason, isProtected) {
|
function shouldRepromptPassphrase(reason, needsCredentials) {
|
||||||
return Model.shouldRepromptPassphrase(reason, isProtected, connectionFailReasons)
|
return Model.shouldRepromptPassphrase(reason, needsCredentials, connectionFailReasons)
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkActionCompletion(network) {
|
function checkActionCompletion(network) {
|
||||||
@@ -756,7 +756,7 @@ Panel {
|
|||||||
else if (actionKind === "forget" && !network.known && !network.stateChanging) clearNetworkAction()
|
else if (actionKind === "forget" && !network.known && !network.stateChanging) clearNetworkAction()
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectKnown(ssid) {
|
function connectDirectly(ssid) {
|
||||||
runNetworkAction("connect", networkForSsid(ssid), function(network) { network.connect() })
|
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;
|
// 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
|
// expands inline to a passphrase prompt when the user picks a network that
|
||||||
// network we don't have credentials for. Clicking a connected row
|
// requires credentials we do not have. Clicking a connected row
|
||||||
// disconnects.
|
// disconnects.
|
||||||
component NetworkRow: CursorSurface {
|
component NetworkRow: CursorSurface {
|
||||||
id: row
|
id: row
|
||||||
@@ -1597,14 +1597,14 @@ Panel {
|
|||||||
|
|
||||||
readonly property bool isConnected: net && net.connected
|
readonly property bool isConnected: net && net.connected
|
||||||
readonly property bool isKnown: !!(net && net.known)
|
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
|
readonly property bool isEnterprise: net
|
||||||
? (net.security === WifiSecurityType.Wpa2Eap || net.security === WifiSecurityType.WpaEap)
|
? (net.security === WifiSecurityType.Wpa2Eap || net.security === WifiSecurityType.WpaEap)
|
||||||
: false
|
: 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 isSelected: root.focusSection === "wifi" && root.selectedIndex === index
|
||||||
readonly property bool forgetFocused: isSelected && root.wifiActionFocused && canForgetFromLock
|
readonly property bool forgetFocused: isSelected && root.wifiActionFocused && canForget
|
||||||
readonly property bool forgetVisible: canForgetFromLock && (forgetFocused || rightMouse.containsMouse)
|
readonly property bool forgetVisible: canForget && (!requiresCredentials || forgetFocused || rightMouse.containsMouse)
|
||||||
|
|
||||||
hasCursor: root.cursorActive && isSelected && !root.wifiActionFocused
|
hasCursor: root.cursorActive && isSelected && !root.wifiActionFocused
|
||||||
current: isConnected
|
current: isConnected
|
||||||
@@ -1631,7 +1631,7 @@ Panel {
|
|||||||
// failNetworkAction, which clears the action state.
|
// failNetworkAction, which clears the action state.
|
||||||
var ours = root.actionKind === "connect" && root.actionSsid === (row.net.ssid || "")
|
var ours = root.actionKind === "connect" && root.actionSsid === (row.net.ssid || "")
|
||||||
root.failNetworkAction(root.networkForSsid(row.net.ssid), reason)
|
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() {
|
function onConnectedChanged() {
|
||||||
if (row.net) root.checkActionCompletion(root.networkForSsid(row.net.ssid))
|
if (row.net) root.checkActionCompletion(root.networkForSsid(row.net.ssid))
|
||||||
@@ -1692,11 +1692,11 @@ Panel {
|
|||||||
root.disconnectRow(row.net.ssid)
|
root.disconnectRow(row.net.ssid)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (row.isProtected && !row.isKnown) {
|
if (row.requiresCredentials && !row.isKnown) {
|
||||||
root.openPasswordPrompt(row.net.ssid)
|
root.openPasswordPrompt(row.net.ssid)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
root.connectKnown(row.net.ssid)
|
root.connectDirectly(row.net.ssid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1719,11 +1719,12 @@ Panel {
|
|||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shows a lock glyph for protected networks. Known disconnected
|
// The right edge shows a lock for networks that require credentials and
|
||||||
// networks reveal the forget action when hovering that right edge.
|
// reveals Forget on hover. Known passwordless networks show Forget
|
||||||
|
// directly rather than reserving an invisible or misleading target.
|
||||||
Item {
|
Item {
|
||||||
id: rightAction
|
id: rightAction
|
||||||
visible: row.isProtected
|
visible: row.requiresCredentials || row.canForget
|
||||||
width: Style.space(22)
|
width: Style.space(22)
|
||||||
implicitHeight: lockIndicator.implicitHeight
|
implicitHeight: lockIndicator.implicitHeight
|
||||||
anchors.right: parent.right
|
anchors.right: parent.right
|
||||||
@@ -1731,6 +1732,7 @@ Panel {
|
|||||||
|
|
||||||
Text {
|
Text {
|
||||||
id: lockIndicator
|
id: lockIndicator
|
||||||
|
visible: row.requiresCredentials || row.forgetVisible
|
||||||
width: parent.width
|
width: parent.width
|
||||||
anchors.verticalCenter: parent.verticalCenter
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
horizontalAlignment: Text.AlignHCenter
|
horizontalAlignment: Text.AlignHCenter
|
||||||
@@ -1754,7 +1756,7 @@ Panel {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
acceptedButtons: Qt.LeftButton
|
acceptedButtons: Qt.LeftButton
|
||||||
enabled: row.canForgetFromLock && !root.busy
|
enabled: row.canForget && !root.busy
|
||||||
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
onContainsMouseChanged: if (containsMouse) { root.cursorActive = true; root.focusSection = "wifi"; root.selectedIndex = row.index; root.wifiActionFocused = true }
|
onContainsMouseChanged: if (containsMouse) { root.cursorActive = true; root.focusSection = "wifi"; root.selectedIndex = row.index; root.wifiActionFocused = true }
|
||||||
onClicked: if (row.net) root.forget(row.net)
|
onClicked: if (row.net) root.forget(row.net)
|
||||||
|
|||||||
@@ -65,8 +65,20 @@ verify_services() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
verify_runtime_tools() {
|
verify_runtime_tools() {
|
||||||
timeout 20 docker info >/dev/null 2>&1 || fail "Docker is usable by the desktop user"
|
# Docker access is intentionally NOT granted to the desktop user: the docker
|
||||||
pass "Docker is usable by the desktop user"
|
# 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"
|
nvim --headless '+qa' >/dev/null 2>&1 || fail "Neovim starts headlessly"
|
||||||
pass "Neovim starts headlessly"
|
pass "Neovim starts headlessly"
|
||||||
|
|||||||
@@ -18,10 +18,12 @@ mkdir -p "$(dirname "$hook_path")"
|
|||||||
cat >"$test_bin/omarchy-notification-send" <<'EOF'
|
cat >"$test_bin/omarchy-notification-send" <<'EOF'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
echo notification >>"$TEST_LOG"
|
echo notification >>"$TEST_LOG"
|
||||||
|
exec_args=()
|
||||||
while (($# > 0)); do
|
while (($# > 0)); do
|
||||||
[[ $1 == "--exec" ]] && echo "exec:$2" >>"$TEST_LOG"
|
if [[ $1 == "--exec" ]]; then shift; exec_args=("$@"); break; fi
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
((${#exec_args[@]})) && echo "exec:${exec_args[*]}" >>"$TEST_LOG"
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$test_bin/omarchy-notification-send"
|
chmod +x "$test_bin/omarchy-notification-send"
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ if rg -q 'barMoveSettling|barMoveSettleTimer' "$ROOT/shell/plugins/bar/Bar.qml";
|
|||||||
fi
|
fi
|
||||||
pass "bar move outline has no post-release settling state"
|
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'
|
run_node_test <<'JS'
|
||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const bar = requireFromRoot('shell/plugins/bar/BarModel.js')
|
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"
|
[[ -z $raw_command_checks ]] || fail "bin commands use command helpers" "$raw_command_checks"
|
||||||
pass "bin commands use command helpers"
|
pass "bin commands use command helpers"
|
||||||
|
|
||||||
raw_notifications=$(rg -l -P '^[[:space:]]*[^#[:space:]].*\bnotify-send\b' "$ROOT/bin" \
|
raw_notifications=$(rg -l -P '^[[:space:]]*[^#[:space:]].*\bnotify-send\b' "$ROOT/bin" || true)
|
||||||
| rg -v '/omarchy-notification-send$' || true)
|
[[ -z $raw_notifications ]] || fail "bin commands use the notification helper, never notify-send" "$raw_notifications"
|
||||||
[[ -z $raw_notifications ]] || fail "bin commands use the notification helper" "$raw_notifications"
|
|
||||||
pass "bin commands use the notification helper"
|
pass "bin commands use the notification helper"
|
||||||
@@ -50,3 +50,202 @@ bash -c '
|
|||||||
' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "javascript:alert(1)" "$ROOT" &&
|
' bash "$ROOT/bin/omarchy-chromium-ytdlp-host" "javascript:alert(1)" "$ROOT" &&
|
||||||
fail "yt-dlp native host rejects non-web URLs"
|
fail "yt-dlp native host rejects non-web URLs"
|
||||||
pass "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"
|
[[ $capture_output == '{"type":"text","text":"closing app copy"}' ]] || fail "clipboard capture records watched text from stdin"
|
||||||
pass "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)
|
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")
|
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"
|
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
|
"$test_home/.local/bin/$command" --version
|
||||||
mapfile -t mise_calls <"$mise_history"
|
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"
|
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
|
source "$ROOT/migrations/1785846769.sh" >/dev/null
|
||||||
[[ ! -s $stub_log ]] || fail "agent migrations respect the preinstall opt-out"
|
[[ ! -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"
|
[[ ! -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"
|
rm "$test_home/.local/state/omarchy/preinstalls-removed"
|
||||||
pass "agent migrations install working wrappers without overriding the preinstall opt-out"
|
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 pi pi "Review this project"
|
||||||
assert_launch omp omp --auto-approve -- "Review this project"
|
assert_launch omp omp --auto-approve -- "Review this project"
|
||||||
assert_launch opencode opencode --auto --prompt "Review this project"
|
assert_launch opencode opencode --auto --prompt "Review this project"
|
||||||
assert_launch claude claude --permission-mode bypassPermissions -- "Review this project"
|
assert_launch claude claude --permission-mode auto -- "Review this project"
|
||||||
assert_launch codex codex --dangerously-bypass-approvals-and-sandbox -- "Review this project"
|
assert_launch codex codex --approve-for-me -- "Review this project"
|
||||||
assert_launch crush crush run "Review this project"
|
assert_launch crush crush run "Review this project"
|
||||||
assert_launch grok grok --permission-mode bypassPermissions -- "Review this project"
|
assert_launch grok grok --permission-mode bypassPermissions -- "Review this project"
|
||||||
assert_launch gemini gemini --yolo --prompt-interactive "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 pi pi
|
||||||
assert_bypass omp omp --auto-approve
|
assert_bypass omp omp --auto-approve
|
||||||
assert_bypass opencode opencode --auto
|
assert_bypass opencode opencode --auto
|
||||||
assert_bypass claude claude --permission-mode bypassPermissions
|
assert_bypass claude claude --permission-mode auto
|
||||||
assert_bypass codex codex --dangerously-bypass-approvals-and-sandbox
|
assert_bypass codex codex --approve-for-me
|
||||||
assert_bypass crush crush --yolo
|
assert_bypass crush crush --yolo
|
||||||
assert_bypass grok grok --permission-mode bypassPermissions
|
assert_bypass grok grok --permission-mode bypassPermissions
|
||||||
assert_bypass gemini gemini --yolo
|
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'
|
cat >"$test_bin/omarchy-notification-send" <<'EOF'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
echo notification >>"$TEST_LOG"
|
echo notification >>"$TEST_LOG"
|
||||||
|
exec_args=()
|
||||||
while (($# > 0)); do
|
while (($# > 0)); do
|
||||||
[[ $1 == "--exec" ]] && echo "exec:$2" >>"$TEST_LOG"
|
if [[ $1 == "--exec" ]]; then shift; exec_args=("$@"); break; fi
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
((${#exec_args[@]})) && echo "exec:${exec_args[*]}" >>"$TEST_LOG"
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$test_bin/omarchy-notification-send"
|
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' ]] ||
|
[[ $errexit_result == $'hit:c:1\nmiss:c:0\nsurvived' ]] ||
|
||||||
fail "guard batch survives a failing reader under errexit" "got: $errexit_result"
|
fail "guard batch survives a failing reader under errexit" "got: $errexit_result"
|
||||||
pass "guard batch survives a reader that exits nonzero under errexit"
|
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"* ]] ||
|
[[ $ROWS == *"Clock"* && $ROWS != *"Weather"* ]] ||
|
||||||
fail "clone picker offers only built-in plugins" "$ROWS"
|
fail "clone picker offers only built-in plugins" "$ROWS"
|
||||||
pass "clone picker offers built-in plugins"
|
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"
|
fail "clone picker delegates cloning and editing to the clone command" "$CALLS"
|
||||||
pass "clone picker clones and opens the personal plugin"
|
pass "clone picker clones and opens the personal plugin"
|
||||||
|
|
||||||
|
|||||||
@@ -172,6 +172,11 @@ assert(
|
|||||||
defaultById['update.omarchy'].iconFont === 'omarchy',
|
defaultById['update.omarchy'].iconFont === 'omarchy',
|
||||||
'menu update Omarchy entry renders the private glyph with the Omarchy font'
|
'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(
|
assert(
|
||||||
defaultById['setup.input'].action.includes('input.lua'),
|
defaultById['setup.input'].action.includes('input.lua'),
|
||||||
'menu keeps Input as a direct config action'
|
'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"
|
notify_args_written || fail "migration notifier sends the notification before exiting"
|
||||||
grep -Fx -- '--exec' "$test_tmp/notify-args" >/dev/null ||
|
grep -Fx -- '--exec' "$test_tmp/notify-args" >/dev/null ||
|
||||||
fail "migration notifier attaches the click command to the toast"
|
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"
|
fail "migration notifier points the click command at omarchy-migrate"
|
||||||
pass "migration notifier lets the shell own the click instead of waiting for it"
|
pass "migration notifier lets the shell own the click instead of waiting for it"
|
||||||
@@ -65,6 +65,18 @@ local omarchy_monitor_scale = "auto"
|
|||||||
LUA
|
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() {
|
write_internal_monitor_config() {
|
||||||
cat >"$monitor_lua" <<'LUA'
|
cat >"$monitor_lua" <<'LUA'
|
||||||
hl.monitor({ output = "eDP-1", mode = "preferred", position = "0x0", scale = 1.25 })
|
hl.monitor({ output = "eDP-1", mode = "preferred", position = "0x0", scale = 1.25 })
|
||||||
@@ -208,20 +220,39 @@ run_clamshell() {
|
|||||||
"$ROOT/bin/omarchy-hyprland-monitor-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
|
write_auto_monitor_config
|
||||||
: >"$eval_log"
|
: >"$eval_log"
|
||||||
OMARCHY_TEST_INTERNAL_SCALE=3 run_clamshell
|
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 = ' "$eval_log" >/dev/null || fail "clamshell recovery leaves an auto-scaled panel alone"
|
||||||
! grep -F 'scale = "auto"' "$eval_log" >/dev/null || fail "clamshell recovery does not apply auto scale"
|
|
||||||
[[ ! -f $scale_state ]] || fail "clamshell recovery does not remember transient scale"
|
[[ ! -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"
|
: >"$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"
|
! grep -F 'scale = ' "$eval_log" >/dev/null || fail "clamshell recovery does not reapply matching scale"
|
||||||
pass "clamshell recovery avoids redundant scale apply"
|
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
|
write_auto_monitor_config
|
||||||
: >"$eval_log"
|
: >"$eval_log"
|
||||||
OMARCHY_TEST_INTERNAL_SCALE=1.6 OMARCHY_TEST_EXTERNAL_ACTIVE=true OMARCHY_TEST_CLAMSHELL=true run_clamshell
|
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"
|
! grep -F 'scale = "auto"' "$eval_log" >/dev/null || fail "clamshell recovery avoids auto after disabled internal display"
|
||||||
pass "clamshell recovery uses remembered internal scale"
|
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
|
write_internal_monitor_config
|
||||||
: >"$eval_log"
|
: >"$eval_log"
|
||||||
OMARCHY_TEST_INTERNAL_DISABLED=true run_clamshell
|
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"
|
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"
|
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.
|
# A quoted value is a string, not a reference to a local of the same name.
|
||||||
write_shadowed_auto_config
|
write_shadowed_auto_config
|
||||||
remember_scale 1.75
|
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"
|
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"
|
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.
|
# Half of `3 / 2` is no better inside the rule than inside a local.
|
||||||
write_expression_rule_config
|
write_expression_rule_config
|
||||||
remember_scale 1.75
|
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