diff --git a/AGENTS.md b/AGENTS.md index 23897a1b..8a8da1c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -262,7 +262,7 @@ This copies `$OMARCHY_PATH/config/hypr/hyprlock.conf` to `~/.config/hypr/hyprloc Read `docs/migrations.md` before creating or changing migrations. -Migrations are per-user and run through `omarchy-migrate` during `omarchy update` or from the migration notification. Put migrations directly under `migrations/.sh`. Pending state is per-user under `~/.local/state/omarchy/migrations/`, so every user gets a chance to run every migration. Migrations run as the user; privileged work should invoke the appropriate helper or privilege prompt, and no-op when another user already applied it. +Migrations are per-user and run through `omarchy-migrate` during `omarchy update` or from the login-time migration notification. Put migrations directly under `migrations/.sh`. Pending state is per-user under `~/.local/state/omarchy/migrations/`, so every user gets a chance to run every migration. Migrations run as the user; privileged work should invoke the appropriate helper or privilege prompt, and no-op when another user already applied it. To create a new migration, run `omarchy-dev-add-migration --no-edit`. diff --git a/bin/omarchy b/bin/omarchy index 7a484eb4..0f50ccc7 100755 --- a/bin/omarchy +++ b/bin/omarchy @@ -79,6 +79,7 @@ GROUP_DESCRIPTIONS[style]="Global UI style controls" GROUP_DESCRIPTIONS[sudo]="Sudo configuration helpers" GROUP_DESCRIPTIONS[system]="System status, reboot, shutdown, logout, and lock" GROUP_DESCRIPTIONS[theme]="Theme management" +GROUP_DESCRIPTIONS[tmux]="Tmux session helpers" GROUP_DESCRIPTIONS[toggle]="Toggle Omarchy features" GROUP_DESCRIPTIONS[transcode]="Image and video transcoding" GROUP_DESCRIPTIONS[tui]="Terminal UI launchers" @@ -143,7 +144,6 @@ register_command() { local name="" local summary="" local usage="" - local binary="" local args="" local examples="" local aliases="" @@ -233,7 +233,6 @@ register_command() { fallback_name="${fallback_name//-/ }" fi - [[ -z $binary ]] && binary="$file_binary" [[ -z $group ]] && group="$fallback_group" [[ $name_seen != "true" ]] && name="$fallback_name" [[ -z $summary && -n $fallback_summary ]] && summary="$fallback_summary" @@ -243,11 +242,9 @@ register_command() { route+=" $name" fi - if [[ -z $usage ]]; then - usage="$route" - if [[ -n $args ]]; then - usage+=" $args" - fi + usage="$route" + if [[ -n $args ]]; then + usage+=" $args" fi [[ $requires_sudo == "true" ]] || requires_sudo="false" @@ -257,7 +254,7 @@ register_command() { COMMAND_KEYS+=("$key") COMMAND_ROUTE["$key"]="$route" COMMAND_FALLBACK_ROUTE["$key"]="$fallback_route" - COMMAND_BINARY["$key"]="$binary" + COMMAND_BINARY["$key"]="$file_binary" COMMAND_GROUP["$key"]="$group" COMMAND_NAME["$key"]="$name" COMMAND_SUMMARY["$key"]="$summary" @@ -269,7 +266,7 @@ register_command() { COMMAND_HAS_SUMMARY["$key"]="$has_summary" COMMAND_METADATA_ERRORS["$key"]="$metadata_errors" - BINARY_TO_KEY["$binary"]="$key" + BINARY_TO_KEY["$file_binary"]="$key" register_route "$route" "$key" register_route "$fallback_route" "$key" @@ -615,25 +612,31 @@ show_commands_markdown() { done < <(sorted_keys "$include_all") } +emit_command_record() { + local key="$1" + + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "${COMMAND_ROUTE[$key]}" \ + "${COMMAND_BINARY[$key]}" \ + "${COMMAND_GROUP[$key]}" \ + "${COMMAND_NAME[$key]}" \ + "${COMMAND_SUMMARY[$key]}" \ + "${COMMAND_REQUIRES_SUDO[$key]}" \ + "${COMMAND_HIDDEN[$key]}" \ + "${COMMAND_ARGS[$key]}" \ + "${COMMAND_EXAMPLES[$key]}" \ + "${COMMAND_ALIASES[$key]}" \ + "${COMMAND_FALLBACK_ROUTE[$key]}" \ + "${COMMAND_USAGE[$key]}" +} + emit_command_records() { local include_all="$1" local key="" while IFS= read -r key; do [[ -n $key ]] || continue - printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ - "${COMMAND_ROUTE[$key]}" \ - "${COMMAND_BINARY[$key]}" \ - "${COMMAND_GROUP[$key]}" \ - "${COMMAND_NAME[$key]}" \ - "${COMMAND_SUMMARY[$key]}" \ - "${COMMAND_REQUIRES_SUDO[$key]}" \ - "${COMMAND_HIDDEN[$key]}" \ - "${COMMAND_ARGS[$key]}" \ - "${COMMAND_EXAMPLES[$key]}" \ - "${COMMAND_ALIASES[$key]}" \ - "${COMMAND_FALLBACK_ROUTE[$key]}" \ - "${COMMAND_USAGE[$key]}" + emit_command_record "$key" done < <(sorted_keys "$include_all") } @@ -702,23 +705,7 @@ show_commands_check() { } show_command_json() { - local key="$1" - - printf '%s\n' "$key" | while IFS= read -r key; do - printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ - "${COMMAND_ROUTE[$key]}" \ - "${COMMAND_BINARY[$key]}" \ - "${COMMAND_GROUP[$key]}" \ - "${COMMAND_NAME[$key]}" \ - "${COMMAND_SUMMARY[$key]}" \ - "${COMMAND_REQUIRES_SUDO[$key]}" \ - "${COMMAND_HIDDEN[$key]}" \ - "${COMMAND_ARGS[$key]}" \ - "${COMMAND_EXAMPLES[$key]}" \ - "${COMMAND_ALIASES[$key]}" \ - "${COMMAND_FALLBACK_ROUTE[$key]}" \ - "${COMMAND_USAGE[$key]}" - done | jq -Rn "$(commands_json_filter) | {ok: true, command: .commands[0]}" + emit_command_record "$1" | jq -Rn "$(commands_json_filter) | {ok: true, command: .commands[0]}" } parse_commands_args() { diff --git a/bin/omarchy-audio-output-set-default b/bin/omarchy-audio-output-set-default index 5f7d8f04..a742a2b3 100755 --- a/bin/omarchy-audio-output-set-default +++ b/bin/omarchy-audio-output-set-default @@ -15,6 +15,17 @@ fi timeout 2 wpctl set-default "$node_id" 2>/dev/null || true timeout 2 pactl set-default-sink "$sink_name" 2>/dev/null || true -timeout 2 pactl list short sink-inputs 2>/dev/null | awk '{ print $1 }' | while read -r input; do +# Move only real application streams. A DSP filter-chain's own output is also a +# sink input but carries no application.name, and moving it would rewire the +# processing itself -- onto headphones, or into its own virtual sink, which is a +# cycle. EasyEffects' output stream must stay put for the same reason. +timeout 2 pactl list sink-inputs 2>/dev/null | awk ' + /^Sink Input #/ {id = substr($3, 2)} + /application\.name = / { + app = $0 + sub(/.*application\.name = "/, "", app) + sub(/"$/, "", app) + if (app != "EasyEffects") print id + }' | while read -r input; do [[ -n $input ]] && timeout 2 pactl move-sink-input "$input" "$sink_name" 2>/dev/null || true done diff --git a/bin/omarchy-audio-output-sink b/bin/omarchy-audio-output-sink new file mode 100755 index 00000000..f47bf984 --- /dev/null +++ b/bin/omarchy-audio-output-sink @@ -0,0 +1,55 @@ +#!/bin/bash + +# omarchy:summary=Print the sink whose volume and mute a given output really uses +# omarchy:args=[sink-name] +# omarchy:group=audio +# omarchy:examples=omarchy audio output sink | omarchy audio output sink omarchy_speaker_tuning + +set -uo pipefail + +# A DSP sink -- a speaker tuning filter-chain, or EasyEffects -- can be the +# selected output without being where loudness lives. Changing its volume alters +# the level going *into* the processing: the display moves while the speakers do +# not, and on a chain with a compressor or limiter the tone changes too. Resolve +# through it to the physical sink it feeds. +# +# With no argument this resolves the current default output, so when headphones or +# HDMI are selected it returns those, not the speakers a tuning happens to front. +# Callers that need to describe some *other* output -- an output switcher naming +# the next one in the rotation -- pass that sink explicitly. + +sink="${1:-$(pactl get-default-sink 2>/dev/null)}" + +if [[ -z $sink || $sink == alsa_output.* ]]; then + printf '%s\n' "$sink" + exit 0 +fi + +# A DSP sink feeds its physical output through a stream of its own; follow that +# stream down to the sink underneath. +downstream="$(pactl list sink-inputs 2>/dev/null | + awk -v virt="$sink" ' + /^Sink Input #/ {target = ""} + /^[[:space:]]*Sink:/ {target = $2} + /node\.name = / { + name = $0 + sub(/.*node\.name = "/, "", name) + sub(/"$/, "", name) + if (index(name, virt) == 1 && target != "") {print target; exit} + } + /application\.name = "EasyEffects"/ { + if (virt == "easyeffects_sink" && target != "") {print target; exit} + }')" + +if [[ -n $downstream ]]; then + name="$(pactl list sinks short 2>/dev/null | + awk -v id="$downstream" '$1 == id {print $2; exit}')" + if [[ -n $name ]]; then + printf '%s\n' "$name" + exit 0 + fi +fi + +# Nothing resolvable downstream -- the DSP sink may simply be idle and unlinked. +# Fall back to the sink itself so callers still have something to act on. +printf '%s\n' "$sink" diff --git a/bin/omarchy-audio-output-switch b/bin/omarchy-audio-output-switch index 73af2246..11d2a356 100755 --- a/bin/omarchy-audio-output-switch +++ b/bin/omarchy-audio-output-switch @@ -2,7 +2,14 @@ # omarchy:summary=Switch between audio outputs while preserving the mute status -sinks=$(timeout 2 pactl -f json list sinks | jq '[.[] | select((.ports | length == 0) or ([.ports[]? | .availability != "not available"] | any))]') +# Skip the physical sink an active speaker tuning fronts: rotating onto it would +# silently bypass the tuning rather than pick a different output. +fronted=$(omarchy-audio-tuning fronted-sink 2>/dev/null || true) + +sinks=$(timeout 2 pactl -f json list sinks | + jq --arg fronted "$fronted" '[.[] + | select((.ports | length == 0) or ([.ports[]? | .availability != "not available"] | any)) + | select($fronted == "" or .name != $fronted)]') sinks_count=$(jq 'length' <<<"$sinks") if (( sinks_count == 0 )); then @@ -22,8 +29,18 @@ fi next_sink=$(jq -c ".[$next_sink_index]" <<<"$sinks") next_sink_name=$(jq -r '.name' <<<"$next_sink") next_sink_description=$(jq -r '.description // .properties."device.description" // .name' <<<"$next_sink") -next_sink_volume=$(jq -r '.volume | to_entries[0].value.value_percent | sub("%"; "") | tonumber' <<<"$next_sink") -next_sink_is_muted=$(jq -r '.mute' <<<"$next_sink") +# A tuning sink sits at a fixed 100% and unmuted while real loudness lives on the +# physical sink beneath it, so read the level from whichever sink actually carries +# it or the OSD contradicts the volume keys. +next_sink_effective=$(omarchy-audio-output-sink "$next_sink_name") +next_sink_volume=$(timeout 2 pactl get-sink-volume "$next_sink_effective" 2>/dev/null | + awk 'NR == 1 {for (i = 1; i <= NF; i++) if ($i ~ /%$/) {sub("%", "", $i); print $i; exit}}') +[[ -n $next_sink_volume ]] || next_sink_volume=$(jq -r '.volume | to_entries[0].value.value_percent | sub("%"; "") | tonumber' <<<"$next_sink") +if [[ $(timeout 2 pactl get-sink-mute "$next_sink_effective" 2>/dev/null) == *yes ]]; then + next_sink_is_muted=true +else + next_sink_is_muted=false +fi if [[ $next_sink_is_muted == "true" ]] || (( next_sink_volume == 0 )); then icon_state="muted" diff --git a/bin/omarchy-audio-output-volume b/bin/omarchy-audio-output-volume index cc9a02c2..22a24aa5 100755 --- a/bin/omarchy-audio-output-volume +++ b/bin/omarchy-audio-output-volume @@ -11,20 +11,27 @@ if [[ -z $action ]]; then exit 1 fi -volume_state() { - wpctl get-volume @DEFAULT_AUDIO_SINK@ -} +# Resolve through any DSP sink to the physical one, so the keys always move real +# loudness and the processing always sees full-scale input. Shared with the audio +# panel and the output switcher. +sink="$(omarchy-audio-output-sink)" +if [[ -z $sink ]]; then + echo "Could not resolve an audio sink to control." >&2 + exit 1 +fi +# pactl reports the same percentage scale wpctl does (both are the raw volume +# over PA_VOLUME_NORM), so the OSD reads identically either way. volume_percent() { - volume_state | awk '{ for (i=1; i<=NF; i++) if ($i ~ /^[0-9.]+$/) print int($i * 100) }' + pactl get-sink-volume "$sink" 2>/dev/null | + awk 'NR == 1 { + for (i = 1; i <= NF; i++) + if ($i ~ /%$/) {sub("%", "", $i); print $i; exit} + }' } volume_muted() { - volume_state | grep -q MUTED -} - -unmute_output() { - wpctl set-mute @DEFAULT_AUDIO_SINK@ 0 >/dev/null + [[ $(pactl get-sink-mute "$sink" 2>/dev/null) == *yes ]] } case "$action" in @@ -37,31 +44,43 @@ if [[ $action == "mute-toggle" ]]; then debounce_file="$runtime_dir/omarchy-audio-output-volume-mute-toggle.last" now=$(date +%s%3N) last=0 - [[ -r $debounce_file ]] && read -r last < "$debounce_file" || true - if (( now - last < 250 )); then + [[ -r $debounce_file ]] && read -r last <"$debounce_file" || true + if ((now - last < 250)); then exit 0 fi - printf '%s\n' "$now" > "$debounce_file" + printf '%s\n' "$now" >"$debounce_file" - wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle >/dev/null -elif [[ $action == +* ]]; then - step="${action#+}" - unmute_output - wpctl set-volume -l 1.0 @DEFAULT_AUDIO_SINK@ "${step}%+" -elif [[ $action == -* ]]; then - step="${action#-}" - unmute_output - wpctl set-volume @DEFAULT_AUDIO_SINK@ "${step}%-" + pactl set-sink-mute "$sink" toggle +elif [[ $action =~ ^([+-])([0-9]+)$ ]]; then + direction="${BASH_REMATCH[1]}" + step="${BASH_REMATCH[2]}" + + current="$(volume_percent)" + if [[ -z $current ]]; then + echo "Could not read volume for $sink." >&2 + exit 1 + fi + + if [[ $direction == "+" ]]; then + next=$((current + step)) + ((next <= 100)) || next=100 + else + next=$((current - step)) + ((next >= 0)) || next=0 + fi + + pactl set-sink-mute "$sink" 0 + pactl set-sink-volume "$sink" "${next}%" else echo "Unknown volume action: $action" exit 1 fi percent=$(volume_percent) -if volume_muted || (( percent == 0 )); then +if volume_muted || ((${percent:-0} == 0)); then icon="volume-muted" else icon="volume-high" fi -omarchy-osd -i "$icon" -p "$percent" +omarchy-osd -i "$icon" -p "${percent:-0}" diff --git a/bin/omarchy-audio-sink-availability b/bin/omarchy-audio-sink-availability index 0f333e26..531b5bff 100755 --- a/bin/omarchy-audio-sink-availability +++ b/bin/omarchy-audio-sink-availability @@ -3,9 +3,18 @@ # omarchy:summary=Print PulseAudio sink availability for the shell # omarchy:group=audio -pactl list sinks 2>/dev/null | awk ' +# A speaker tuning is a virtual sink in front of the real speakers. Both exist +# in the graph, but selecting the physical one would only bypass the tuning, so +# report it unavailable and keep it out of the output list. +fronted="$(omarchy-audio-tuning fronted-sink 2>/dev/null || true)" + +pactl list sinks 2>/dev/null | awk -v fronted="$fronted" ' function emit_sink() { if (name == "") return + if (fronted != "" && name == fronted) { + print name "\t0" + return + } print name "\t" ((port_count == 0 || available) ? 1 : 0) } diff --git a/bin/omarchy-audio-tuning b/bin/omarchy-audio-tuning new file mode 100755 index 00000000..f238de46 --- /dev/null +++ b/bin/omarchy-audio-tuning @@ -0,0 +1,357 @@ +#!/bin/bash + +# omarchy:summary=Manage the speaker tuning for this laptop +# omarchy:args= [--force] +# omarchy:group=audio +# omarchy:examples=omarchy audio tuning status | omarchy audio tuning on | omarchy audio tuning off + +set -uo pipefail + +tunings_dir="$OMARCHY_PATH/default/audio/tunings" +config_home="${XDG_CONFIG_HOME:-$HOME/.config}" + +# The tuning is hosted by its own PipeWire client, under its own config name, so +# switching it needs no audio restart -- a restart drops every PulseAudio client's +# connection, and applications that do not reconnect (Spotify) then have to be +# restarted by hand. The name is deliberately not PipeWire's stock +# filter-chain.conf, which merges every fragment in filter-chain.conf.d/ and would +# make this service host unrelated user filters too. +host_config_name=omarchy-speaker-tuning.conf +host_config="$config_home/pipewire/$host_config_name" +host_source="$OMARCHY_PATH/default/audio/filter-chain-host.conf" +fragment="$config_home/pipewire/$host_config_name.d/90-tuning.conf" +unit_name=omarchy-speaker-tuning.service +unit="$config_home/systemd/user/$unit_name" +unit_source="$OMARCHY_PATH/default/systemd/user/$unit_name" + +# Earlier revisions loaded the tuning into the daemon, as a WirePlumber smart +# filter, or into the shared filter-chain.conf.d namespace. Remove all three so +# they cannot be loaded alongside the current one. +stale_daemon="$config_home/pipewire/pipewire.conf.d/90-omarchy-speaker-tuning.conf" +stale_wireplumber="$config_home/wireplumber/wireplumber.conf.d/90-omarchy-speaker-tuning.conf" +stale_shared="$config_home/pipewire/filter-chain.conf.d/90-omarchy-speaker-tuning.conf" + +sink_name=omarchy_speaker_tuning + +action="${1:-status}" +force=0 +[[ ${2:-} == "--force" ]] && force=1 + +sink_matching() { + pactl list sinks short 2>/dev/null | awk -v p="$1" '$2 ~ p {print $2; exit}' +} + +# Dell keys its Cirrus speaker firmware on the DMI product SKU, which makes it the +# most precise identifier available for these machines -- narrower than a product +# name, and it distinguishes models whose names differ only by marketing. Compared +# case-insensitively against an exact SKU, never a substring, so a tuning cannot +# accidentally widen to a whole product line. +sku_matches() { + local sku want + sku="$(cat /sys/class/dmi/id/product_sku 2>/dev/null)" + [[ -n $sku ]] || return 1 + for want in "$@"; do + [[ ${sku,,} == "${want,,}" ]] && return 0 + done + return 1 +} + +dmi_matches() { + local want + for want in "$@"; do + omarchy-hw-match "$want" 2>/dev/null && return 0 + done + return 1 +} + +# Print the tuning directory matching this laptop, if any. Matching is data, not +# code: a tuning declares the DMI string it belongs to and the sink it expects, so +# most tunings can be added as a directory with no new script. A tuning whose +# hardware needs a sharper test can set match_command to any predicate instead. +tuning_match() { + local dir + for dir in "$tunings_dir"/*/; do + [[ -r $dir/tuning.conf ]] || continue + + unset match_dmi match_sku match_command sink_pattern + # shellcheck disable=SC1090 + source "$dir/tuning.conf" + + # Deliberately does not look at the live audio graph. The install hooks run in + # the ISO chroot with no audio server, and a match that depended on a present + # sink would come back empty there -- so the machine would get neither the LV2 + # dependency nor the tuning, and nothing would retry. + # A tuning may list several models it has been validated on. match_dmi and + # match_sku are arrays, so a plain string still works as a single entry. + if [[ -n ${match_command:-} ]]; then + "$match_command" 2>/dev/null || continue + elif [[ -n ${match_sku:-} ]]; then + sku_matches "${match_sku[@]}" || continue + elif [[ -n ${match_dmi:-} ]]; then + dmi_matches "${match_dmi[@]}" || continue + else + continue + fi + + # Required whichever way the tuning matched: the graph's target sink is + # substituted from it, so a tuning without one cannot be installed and must + # not be reported as a match. + [[ -n ${sink_pattern:-} ]] || continue + + printf '%s\n' "${dir%/}" + return 0 + done + return 1 +} + +# The physical sink the matched tuning is built for, taken from the tuning's own +# sink_pattern rather than a hard-coded regex, so hardware with a different sink +# name needs no change here. +tuned_hardware_sink() { + local dir found + dir="$(tuning_match)" || return 1 + unset sink_pattern + # shellcheck disable=SC1090 + source "$dir/tuning.conf" + [[ -n ${sink_pattern:-} ]] || return 1 + found="$(sink_matching "$sink_pattern")" + [[ -n $found ]] || return 1 + printf '%s\n' "$found" +} + +tuning_present() { + pactl list sinks short 2>/dev/null | awk '{print $2}' | grep -qx "$sink_name" +} + +# Only real application streams may be moved. A filter-chain's own output is also +# a sink input but carries no application.name, and moving it would rewire the +# tuning itself. +app_streams() { + pactl list sink-inputs 2>/dev/null | awk ' + /^Sink Input #/ {id = substr($3, 2)} + /application\.name = / { + app = $0 + sub(/.*application\.name = "/, "", app) + sub(/"$/, "", app) + if (app != "EasyEffects") print id + }' +} + +move_apps_to() { + local target="$1" id + for id in $(app_streams); do + pactl move-sink-input "$id" "$target" 2>/dev/null || true + done +} + +# WirePlumber can link the output elsewhere if the target is missing when the host +# starts. node.dont-fallback guards against it, but verify rather than assume. +tuning_downstream_sink() { + omarchy-audio-output-sink "$sink_name" 2>/dev/null +} + +easyeffects_running() { + pactl list sinks short 2>/dev/null | awk '{print $2}' | grep -qx easyeffects_sink || + pgrep -u "$(id -u)" -x easyeffects >/dev/null 2>&1 || + systemctl --user is-active --quiet easyeffects.service 2>/dev/null +} + +# Unloading a daemon-loaded drop-in is the one case that still needs an audio +# restart, because the daemon only reads its own config at startup. +drop_stale_daemon_config() { + [[ -e $stale_daemon || -e $stale_wireplumber ]] || return 0 + rm -f "$stale_daemon" "$stale_wireplumber" + omarchy-restart-audio >/dev/null 2>&1 + local _ + for _ in {1..40}; do + pactl info >/dev/null 2>&1 && break + sleep 0.25 + done +} + +case "$action" in + match) + tuning_match + ;; + + fronted-sink) + # The tuning is a virtual sink in front of the real speakers, so both exist in + # the graph. Selecting the physical one would only bypass the tuning, so + # callers keep it out of the output list while the tuning is up. This answers + # "is a tuning in place", not "where should volume go" -- for the latter see + # omarchy-audio-output-sink, which follows the current default output. + tuning_present || exit 1 + tuned_hardware_sink + ;; + + status) + if [[ -r $fragment ]]; then + echo "Installed: yes ($fragment)" + else + echo "Installed: no" + fi + # Both is-active and is-enabled print their answer *and* exit non-zero when + # negative, so a "|| echo" fallback prints it twice. + host_state="$(systemctl --user is-active "$unit_name" 2>/dev/null)" + host_enabled="$(systemctl --user is-enabled "$unit_name" 2>/dev/null)" + echo "Host service: ${host_state:-inactive} (${host_enabled:-disabled})" + if tuning_present; then + echo "Tuning sink: present" + else + echo "Tuning sink: absent" + fi + echo "Default sink: $(pactl get-default-sink 2>/dev/null)" + if dir="$(tuning_match)"; then + unset description + # shellcheck disable=SC1090 + source "$dir/tuning.conf" + echo "Matches: ${description:-?} ($(basename "$dir"))" + else + echo "Matches: nothing ships for this laptop" + fi + ;; + + off) + if [[ ! -r $fragment && ! -r $unit && ! -r $stale_daemon && ! -r $stale_wireplumber && + ! -r $stale_shared ]]; then + echo "No speaker tuning installed." + exit 0 + fi + + speakers="$(tuned_hardware_sink)" || speakers="" + + systemctl --user disable --now "$unit_name" >/dev/null 2>&1 + rm -f "$fragment" "$host_config" "$unit" "$stale_shared" + rmdir "$config_home/pipewire/$host_config_name.d" 2>/dev/null + systemctl --user daemon-reload >/dev/null 2>&1 + drop_stale_daemon_config + + for _ in {1..20}; do + tuning_present || break + sleep 0.25 + done + + if [[ -n $speakers ]]; then + pactl set-default-sink "$speakers" >/dev/null 2>&1 + # Streams left on the vanished tuning sink reconnect wherever PipeWire puts + # them, which is not necessarily the speakers. + move_apps_to "$speakers" + fi + echo "Speaker tuning removed." + ;; + + on) + [[ -d $tunings_dir ]] || { + echo "No tunings shipped at $tunings_dir" >&2 + exit 1 + } + + selected="$(tuning_match)" || { + echo "No speaker tuning matches this laptop." + exit 0 + } + + unset description sink_pattern + # shellcheck disable=SC1090 + source "$selected/tuning.conf" + + # At first-run the session is up but the sink can still be settling. + for _ in {1..20}; do + speaker_sink="$(sink_matching "$sink_pattern")" + [[ -n $speaker_sink ]] && break + sleep 0.5 + done + [[ -n ${speaker_sink:-} ]] || { + echo "A tuning applies to this laptop but no sink matching $sink_pattern" >&2 + echo "is present, so there is no audio server yet. Re-run after login:" >&2 + echo " omarchy audio tuning on" >&2 + exit 1 + } + + if easyeffects_running; then + cat >&2 <<'EOF' +EasyEffects is running. It moves any stream that follows the default sink to its +own sink, so a tuning installed now would be bypassed. + +Stop it first: systemctl --user disable --now easyeffects.service +EOF + exit 1 + fi + + # Every tuning ends in a limiter, which is an LV2 plugin. Without it the graph + # fails to instantiate and the tuning sink never appears. + ls /usr/lib/lv2/lsp-plugins.lv2/limiter_stereo.ttl >/dev/null 2>&1 || { + echo "lsp-plugins-lv2 is required for the tuning limiter." >&2 + exit 1 + } + + rendered="$(mktemp)" + trap 'rm -f "$rendered"' EXIT + sed "s|@SPEAKER_SINK@|$speaker_sink|g" "$selected/filter-chain.conf" >"$rendered" + + # Everything that makes the tuning current has to match, not just the graph: + # an active-but-disabled service disappears at next login, and a stale unit + # file would shadow later fixes to the shipped one indefinitely. + if ((!force)) && [[ -r $fragment ]] && cmp -s "$rendered" "$fragment" && + [[ -r $host_config ]] && cmp -s "$host_source" "$host_config" && + [[ -r $unit ]] && cmp -s "$unit_source" "$unit" && + systemctl --user is-active --quiet "$unit_name" 2>/dev/null && + systemctl --user is-enabled --quiet "$unit_name" 2>/dev/null && + [[ "$(tuning_downstream_sink)" == "$speaker_sink" ]]; then + echo "Speaker tuning already current: $description" + exit 0 + fi + + drop_stale_daemon_config + + rm -f "$stale_shared" + install -Dm644 "$host_source" "$host_config" + install -Dm644 "$rendered" "$fragment" + install -Dm644 "$unit_source" "$unit" + systemctl --user daemon-reload >/dev/null 2>&1 + systemctl --user enable "$unit_name" >/dev/null 2>&1 + systemctl --user restart "$unit_name" >/dev/null 2>&1 + echo "Installed speaker tuning: $description" + + for _ in {1..40}; do + tuning_present && break + sleep 0.25 + done + if ! tuning_present; then + systemctl --user disable --now "$unit_name" >/dev/null 2>&1 + rm -f "$fragment" "$host_config" "$unit" + systemctl --user daemon-reload >/dev/null 2>&1 + echo "Tuning sink never appeared, so it was removed. Audio is untouched." >&2 + echo "Check: systemctl --user status $unit_name" >&2 + exit 1 + fi + + # Confirm the output really landed on the sink this tuning was measured for. + for _ in {1..20}; do + [[ "$(tuning_downstream_sink)" == "$speaker_sink" ]] && break + sleep 0.25 + done + downstream="$(tuning_downstream_sink)" + if [[ $downstream != "$speaker_sink" ]]; then + systemctl --user disable --now "$unit_name" >/dev/null 2>&1 + rm -f "$fragment" "$host_config" "$unit" + systemctl --user daemon-reload >/dev/null 2>&1 + echo "The tuning output linked to ${downstream:-nothing} instead of" >&2 + echo "$speaker_sink, so it was removed rather than left tuning the wrong" >&2 + echo "device. Audio is untouched." >&2 + exit 1 + fi + + pactl set-default-sink "$sink_name" >/dev/null 2>&1 + # A default sink only captures newly created streams, so anything already + # playing would keep bypassing the tuning until its app was restarted. + move_apps_to "$sink_name" + + echo "Speakers now play through the tuning." + ;; + + *) + echo "Usage: omarchy-audio-tuning [--force]" >&2 + exit 2 + ;; +esac diff --git a/bin/omarchy-bar b/bin/omarchy-bar index eb41c96b..36c62092 100755 --- a/bin/omarchy-bar +++ b/bin/omarchy-bar @@ -7,9 +7,7 @@ set -euo pipefail -CONFIG_FILE="$HOME/.config/omarchy/shell.json" -OMARCHY_ROOT="${OMARCHY_PATH:-}" -DEFAULTS_FILE="$OMARCHY_ROOT/config/omarchy/shell.json" +source omarchy-shell-config usage() { cat <&2 - exit 1 -} - -refresh_shell_config() { - if ! omarchy-shell shell reloadConfig >/dev/null 2>&1; then - omarchy-shell -q shell rescanPlugins >/dev/null 2>&1 || true - fi -} - -source_file() { - if [[ -s $CONFIG_FILE ]]; then - printf '%s\n' "$CONFIG_FILE" - else - printf '%s\n' "$DEFAULTS_FILE" - fi -} - -# jq pipeline that normalizes shell.json into a well-shaped object with -# version=1, bar.layout.{left,center,right} arrays, and plugins array. -NORMALIZE=' - def object_or_empty: if type == "object" then . else {} end; - def array_or_empty: if type == "array" then . else [] end; - object_or_empty - | .version = 1 - | .bar = (.bar | object_or_empty) - | .bar.layout = (.bar.layout | object_or_empty) - | .bar.layout.left = (.bar.layout.left | array_or_empty) - | .bar.layout.center = (.bar.layout.center | array_or_empty) - | .bar.layout.right = (.bar.layout.right | array_or_empty) - | .plugins = (.plugins | array_or_empty) -' - -# Apply a jq program to the source file and atomically write the result to the -# user config, then refresh the running shell. Extra args after the program are -# forwarded to jq (e.g. --arg/--argjson). -_BAR_TMP="" -cleanup_bar_tmp() { - if [[ -n ${_BAR_TMP:-} ]]; then rm -f "$_BAR_TMP"; fi -} -trap cleanup_bar_tmp EXIT - -commit() { - local program="$1" - shift - mkdir -p "$(dirname "$CONFIG_FILE")" - _BAR_TMP=$(mktemp) - jq -S -e "$@" "$program" "$(source_file)" >"$_BAR_TMP" || fail "could not update shell config" - mv "$_BAR_TMP" "$CONFIG_FILE" - _BAR_TMP="" - refresh_shell_config -} - # ------------------------------------------------------------------ validation bar_option_exists() { @@ -99,7 +43,7 @@ bar_option_exists() { cmd_use() { local plugin="${1:-}" [[ -n $plugin ]] || fail "bar option id is required" - [[ $# -eq 1 ]] || fail "use takes a single bar option id" + (( $# == 1 )) || fail "use takes a single bar option id" if [[ $plugin == "default" || $plugin == "built-in" ]]; then plugin="omarchy.bar" fi @@ -130,7 +74,7 @@ cmd_defaults() { cmd_position() { local position="${1:-}" [[ -n $position ]] || fail "position is required" - [[ $# -eq 1 ]] || fail "position takes a single value" + (( $# == 1 )) || fail "position takes a single value" [[ $position =~ ^(top|bottom|left|right)$ ]] || fail "position must be top, bottom, left, or right" commit "$NORMALIZE | .bar.position = \$position" --arg position "$position" echo "Bar position set to $position" @@ -139,7 +83,7 @@ cmd_position() { cmd_transparent() { local transparent="${1:-}" [[ -n $transparent ]] || fail "transparent is required" - [[ $# -eq 1 ]] || fail "transparent takes a single value" + (( $# == 1 )) || fail "transparent takes a single value" [[ $transparent =~ ^(true|false|toggle)$ ]] || fail "transparent must be true, false, or toggle" if [[ $transparent == "toggle" ]]; then commit "$NORMALIZE | .bar.transparent = (.bar.transparent != true)" @@ -153,7 +97,7 @@ cmd_transparent() { # --------------------------------------------------------------------- dispatch command="${1:-}" -[[ $# -gt 0 ]] && shift || true +(( $# > 0 )) && shift || true case "$command" in use) diff --git a/bin/omarchy-bar-plugin b/bin/omarchy-bar-plugin index 19fabef2..c7442f10 100755 --- a/bin/omarchy-bar-plugin +++ b/bin/omarchy-bar-plugin @@ -3,13 +3,11 @@ # omarchy:summary=Add, move, remove, and configure bar plugin widgets in the layout # omarchy:group=bar # omarchy:args=add [placement] | move [placement] | remove [placement] | set [--json] [placement] | replace -# omarchy:examples=omarchy bar plugin add omarchy.tailscale | omarchy bar plugin add omarchy.clock --section center --before omarchy.weather | omarchy bar plugin move omarchy.clock --section center --index 0 | omarchy bar plugin remove omarchy.tailscale | omarchy bar plugin set omarchy.clock format HH:mm +# omarchy:examples=omarchy bar plugin add omarchy.tailscale | omarchy bar plugin add omarchy.clock center | omarchy bar plugin move omarchy.media left | omarchy bar plugin move omarchy.clock --section center --index 0 | omarchy bar plugin remove omarchy.tailscale | omarchy bar plugin set omarchy.clock format HH:mm set -euo pipefail -CONFIG_FILE="$HOME/.config/omarchy/shell.json" -OMARCHY_ROOT="${OMARCHY_PATH:-}" -DEFAULTS_FILE="$OMARCHY_ROOT/config/omarchy/shell.json" +source omarchy-shell-config usage() { cat <&2 - exit 1 -} - -refresh_shell_config() { - if ! omarchy-shell shell reloadConfig >/dev/null 2>&1; then - omarchy-shell -q shell rescanPlugins >/dev/null 2>&1 || true - fi -} - -source_file() { - if [[ -s $CONFIG_FILE ]]; then - printf '%s\n' "$CONFIG_FILE" - else - printf '%s\n' "$DEFAULTS_FILE" - fi -} - -# jq defs shared across mutations. All `def`s come first so the pipeline that -# follows them stays valid jq. `entry_id` and the shape helpers are used by both -# the normalize pipeline and the resolve_target/resolve_source helpers. +# jq defs used by the mutation pipelines. All `def`s come first so the pipeline +# that follows them stays valid jq; NORMALIZE adds its own shape helpers. JQ_DEFS=' - def object_or_empty: if type == "object" then . else {} end; - def array_or_empty: if type == "array" then . else [] end; def entry_id: if type == "object" then (.id // "" | tostring) else tostring end; def anchor_for($section): { left: "omarchy.workspaces", center: "omarchy.weather", right: "omarchy.tray" }[$section]; def find_all($id; $only): @@ -130,40 +107,6 @@ JQ_DEFS=' end; ' -# jq pipeline that normalizes shell.json into a well-shaped object with -# version=1, bar.layout.{left,center,right} arrays, and plugins array. Every -# mutation pipes through this so downstream jq can assume structure. -NORMALIZE=' - object_or_empty - | .version = 1 - | .bar = (.bar | object_or_empty) - | .bar.layout = (.bar.layout | object_or_empty) - | .bar.layout.left = (.bar.layout.left | array_or_empty) - | .bar.layout.center = (.bar.layout.center | array_or_empty) - | .bar.layout.right = (.bar.layout.right | array_or_empty) - | .plugins = (.plugins | array_or_empty) -' - -# Apply a jq program to the source file and atomically write the result to the -# user config, then refresh the running shell. Extra args after the program are -# forwarded to jq (e.g. --arg/--argjson). -_BAR_TMP="" -cleanup_bar_tmp() { - if [[ -n ${_BAR_TMP:-} ]]; then rm -f "$_BAR_TMP"; fi -} -trap cleanup_bar_tmp EXIT - -commit() { - local program="$1" - shift - mkdir -p "$(dirname "$CONFIG_FILE")" - _BAR_TMP=$(mktemp) - jq -S -e "$@" "$program" "$(source_file)" >"$_BAR_TMP" || fail "could not update shell config" - mv "$_BAR_TMP" "$CONFIG_FILE" - _BAR_TMP="" - refresh_shell_config -} - validate_section() { [[ $1 =~ ^(left|center|right)$ ]] || fail "section must be left, center, or right" } @@ -323,7 +266,26 @@ cmd_move() { local id="${1:-}" [[ -n $id ]] || fail "move requires a widget id" shift + + local positional_section="" + while (( $# > 0 )); do + if [[ $1 == --* ]]; then + break + fi + [[ -z $positional_section ]] || fail "unexpected argument: $1" + positional_section="$1" + shift + done + parse_placement "$@" + [[ -z $positional_section || -z $PLACEMENT_SECTION ]] || fail "specify a section positionally or with --section, not both" + [[ -z $positional_section || -z $PLACEMENT_INDEX ]] || fail "specify a section positionally or use --index, not both" + [[ -z $positional_section || -z $PLACEMENT_BEFORE ]] || fail "specify a section positionally or use --before, not both" + [[ -z $positional_section || -z $PLACEMENT_AFTER ]] || fail "specify a section positionally or use --after, not both" + if [[ -n $positional_section ]]; then + validate_section "$positional_section" + PLACEMENT_SECTION="$positional_section" + fi local default_section="${PLACEMENT_SECTION:-}" local prog @@ -420,7 +382,7 @@ cmd_set() { parse_placement "$@" if [[ $value_is_json == "true" ]]; then - jq -e . <<<"$value" >/dev/null 2>&1 || fail "invalid JSON value: $value" + jq -n --argjson value "$value" empty >/dev/null 2>&1 || fail "invalid JSON value: $value" fi local value_arg @@ -474,7 +436,7 @@ cmd_replace() { local new="${2:-}" [[ -n $old ]] || fail "replace requires the source widget id" [[ -n $new ]] || fail "replace requires the replacement widget id" - [[ $# -eq 2 ]] || fail "replace takes two widget ids" + (( $# == 2 )) || fail "replace takes two widget ids" local prog prog=$(cat < 0 )) && shift || true case "$command" in add) diff --git a/bin/omarchy-capture-screenrecording b/bin/omarchy-capture-screenrecording index 49ffa04e..4db875d0 100755 --- a/bin/omarchy-capture-screenrecording +++ b/bin/omarchy-capture-screenrecording @@ -35,6 +35,7 @@ RESOLUTION="" FULLSCREEN="false" STOP_RECORDING="false" RECORDING_FILE="/tmp/omarchy-screenrecord-filename" +REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region" LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "/tmp/omarchy-screenrecord.log" || echo "/dev/null") for arg in "$@"; do @@ -89,11 +90,26 @@ start_webcam_overlay() { --title="WebcamOverlay" --wayland-app-id="WebcamOverlay-$WEBCAM_SIZE" \ --no-border --no-audio --no-osc --osd-level=0 \ --really-quiet &>/dev/null & - sleep 1 + + # The move has to settle before gpu-screen-recorder starts, or the camera is + # recorded sliding into its corner. Waiting for the map is what the blind + # second was partly guessing at, so the remainder is trimmed to hold the + # pre-capture delay where it was: starting later costs the first words spoken. + local waited=0 + while ((waited < 40)) && ! hyprctl clients -j | jq -e 'any(.[]; .title == "WebcamOverlay")' >/dev/null 2>&1; do + sleep 0.05 + ((waited++)) + done + + [[ ${1:-} == region:* ]] && echo "${1#region:}" >"$REGION_FILE" + omarchy-capture-webcam-resize "$WEBCAM_SIZE" + + sleep 0.6 } cleanup_webcam() { pkill -f "WebcamOverlay" 2>/dev/null + rm -f "$REGION_FILE" } default_resolution() { @@ -155,7 +171,7 @@ start_screenrecording() { esac fi - [[ $WEBCAM == "true" ]] && start_webcam_overlay + [[ $WEBCAM == "true" ]] && start_webcam_overlay "$target" local filename="$OUTPUT_DIR/screenrecording-$(date +'%Y-%m-%d_%H-%M-%S').mp4" local audio_devices="" diff --git a/bin/omarchy-capture-webcam-resize b/bin/omarchy-capture-webcam-resize index ebe9840f..cde61e08 100755 --- a/bin/omarchy-capture-webcam-resize +++ b/bin/omarchy-capture-webcam-resize @@ -8,6 +8,7 @@ set -euo pipefail readonly MARGIN=40 +readonly REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region" usage() { echo "Usage: omarchy-capture-webcam-resize " >&2 @@ -57,13 +58,39 @@ read -r monitor_x monitor_y monitor_width monitor_height < <( [[ $monitor_x =~ ^-?[0-9]+$ && $monitor_y =~ ^-?[0-9]+$ && $monitor_width =~ ^[0-9]+$ && $monitor_height =~ ^[0-9]+$ ]] || exit 0 -# Scale the 8:9 portrait presets from monitor height so they occupy the same +# Anchor to the recorded region when there is one, so a window picked on a wide +# display keeps the camera in its own corner. Full-monitor captures, the portal +# backend, and resizes outside a recording publish none and fall back here. +anchor_x=$monitor_x +anchor_y=$monitor_y +anchor_width=$monitor_width +anchor_height=$monitor_height + +if [[ -f $REGION_FILE ]] && region=$(<"$REGION_FILE"); then + if [[ $region =~ ^([0-9]+)x([0-9]+)\+(-?[0-9]+)\+(-?[0-9]+)$ ]]; then + anchor_width=${BASH_REMATCH[1]} + anchor_height=${BASH_REMATCH[2]} + anchor_x=${BASH_REMATCH[3]} + anchor_y=${BASH_REMATCH[4]} + fi +fi + +# A tall, narrow region can't fit presets scaled from its own height, so cap the +# height they scale from to what the width allows — the large preset is the +# widest at 3/10 of it. Scaling the ladder as a whole leaves small, medium and +# large distinct sizes for smaller and larger to step between. +scale_height=$anchor_height +available_width=$((anchor_width - 2 * MARGIN)) +((available_width > 0 && scale_height * 3 / 10 > available_width)) && + scale_height=$((available_width * 10 / 3)) + +# Scale the 8:9 portrait presets from that height so they occupy the same # proportion of a 1080p, HiDPI, ultrawide, or 6K recording. -small_height=$(((monitor_height * 9 + 25) / 50)) +small_height=$(((scale_height * 9 + 25) / 50)) small_width=$(((small_height * 8 + 4) / 9)) -medium_height=$(((monitor_height + 2) / 4)) +medium_height=$(((scale_height + 2) / 4)) medium_width=$(((medium_height * 8 + 4) / 9)) -large_height=$(((monitor_height * 27 + 40) / 80)) +large_height=$(((scale_height * 27 + 40) / 80)) large_width=$(((large_height * 8 + 4) / 9)) target_width=$current_width @@ -107,11 +134,11 @@ larger) ;; esac -target_x=$((monitor_x + monitor_width - target_width - MARGIN)) -target_y=$((monitor_y + monitor_height - target_height - MARGIN)) +target_x=$((anchor_x + anchor_width - target_width - MARGIN)) +target_y=$((anchor_y + anchor_height - target_height - MARGIN)) -((target_x < monitor_x + MARGIN)) && target_x=$((monitor_x + MARGIN)) -((target_y < monitor_y + MARGIN)) && target_y=$((monitor_y + MARGIN)) +((target_x < anchor_x + MARGIN)) && target_x=$((anchor_x + MARGIN)) +((target_y < anchor_y + MARGIN)) && target_y=$((anchor_y + MARGIN)) window="address:$address" hypr_dispatch \ diff --git a/bin/omarchy-channel-set b/bin/omarchy-channel-set index 704fb7ba..7e112388 100755 --- a/bin/omarchy-channel-set +++ b/bin/omarchy-channel-set @@ -12,64 +12,38 @@ fail() { echo "Error: $*" >&2; exit 1; } confirm_dev() { cat <<'WARNING' -Dev links Omarchy directly to a mutable source checkout. - -This disables package-based protections for the Omarchy code path, including -normal package updates and package-backed snapshot restores. You'll be -responsible for pulling, fixing, and restoring that checkout yourself. +The dev channel links Omarchy directly to a checkout of the source in ~/omarchy. +It's exclusively intended for developers working on Omarchy itself. WARNING - gum confirm --default=false "Enable Dev anyway?" + gum confirm --default=false "Switch to dev channel?" } -choose_dev_checkout() { - local default_path="$HOME/Work/omarchy" - local path="${OMARCHY_DEV_PATH:-}" - - if [[ -z $path ]]; then - path=$(gum input --value "$default_path" --placeholder "$default_path" --header "Where should Dev checkout live? Existing non-checkout paths will not be overwritten.") || exit 1 - fi - - path="${path:-$default_path}" - case "$path" in - "~") path="$HOME" ;; - "~/"*) path="$HOME/${path#~/}" ;; - /*) ;; - *) path="$PWD/$path" ;; - esac - - if [[ -e $path && ! -d $path/.git ]]; then - fail "$path already exists and is not a git checkout. Choose an empty path or an existing Omarchy checkout." - fi - - if [[ -d $path/.git && ( ! -d $path/bin || ! -d $path/default || ! -d $path/shell ) ]]; then - fail "$path is a git checkout, but it does not look like Omarchy." - fi - - printf '%s\n' "$path" -} - -sync_dev_checkout() { +validate_dev_checkout() { local checkout="$1" - if [[ -d $checkout/.git ]]; then - echo "Updating Dev checkout at $checkout" - git -C "$checkout" fetch origin quattro - git -C "$checkout" checkout quattro 2>/dev/null || git -C "$checkout" checkout --track origin/quattro - git -C "$checkout" pull --ff-only origin quattro - else - mkdir -p "$(dirname -- "$checkout")" - git clone --branch quattro --single-branch https://github.com/basecamp/omarchy.git "$checkout" + if [[ -e $checkout && ! -d $checkout/.git ]]; then + fail "$checkout already exists and is not a git checkout." fi - omarchy-dev-link "$checkout" + if [[ -d $checkout/.git && ( ! -d $checkout/bin || ! -d $checkout/default || ! -d $checkout/shell ) ]]; then + fail "$checkout is a git checkout, but it does not look like Omarchy." + fi +} + +link_dev_checkout() { + local checkout="$1" + [[ -d $checkout/.git ]] || git clone https://github.com/basecamp/omarchy.git "$checkout" + + omarchy-dev-link "$checkout" --no-reboot } (( $# > 0 )) || { usage; exit 1; } dev_checkout="" channel="$1" +leaving_dev=0 case "$channel" in stable) @@ -86,7 +60,8 @@ case "$channel" in ;; dev) confirm_dev || { echo "Cancelled."; exit 0; } - dev_checkout=$(choose_dev_checkout) + dev_checkout="$HOME/omarchy" + validate_dev_checkout "$dev_checkout" pacman_channel=edge packages=(omarchy-dev omarchy-settings-dev) ;; @@ -97,17 +72,28 @@ case "$channel" in ;; esac +if [[ -z $dev_checkout && $OMARCHY_PATH != "/usr/share/omarchy" ]]; then + leaving_dev=1 +fi + +if [[ -n $dev_checkout ]]; then + link_dev_checkout "$dev_checkout" + export OMARCHY_PATH="$dev_checkout" + export PATH="$OMARCHY_PATH/bin:$PATH" + omarchy-state set reboot-required +fi + omarchy-refresh-pacman "$pacman_channel" # --ask 4 accepts omarchy <-> omarchy-dev replacement prompts without file overwrites. sudo env OMARCHY_UPDATE_PACMAN=1 pacman -S --needed --noconfirm --ask 4 "${packages[@]}" -if [[ -z $dev_checkout ]] && omarchy-cmd-present omarchy-dev-unlink; then - omarchy-dev-unlink +if [[ -z $dev_checkout ]]; then + omarchy-dev-unlink --no-reboot export OMARCHY_PATH=/usr/share/omarchy + + if (( leaving_dev )); then + omarchy-state set reboot-required + fi fi omarchy-update -y - -if [[ -n $dev_checkout ]]; then - sync_dev_checkout "$dev_checkout" -fi diff --git a/bin/omarchy-clipboard-open b/bin/omarchy-clipboard-open index c4d08709..98f26bb2 100755 --- a/bin/omarchy-clipboard-open +++ b/bin/omarchy-clipboard-open @@ -8,7 +8,7 @@ history_index="" history_path="$HOME/.local/state/omarchy/clipboard-history.json" -while [[ $# -gt 0 ]]; do +while (( $# > 0 )); do case "$1" in --history-index) history_index="${2:-}" diff --git a/bin/omarchy-clipboard-paste-text b/bin/omarchy-clipboard-paste-text index ac0781a6..b8ac8e31 100755 --- a/bin/omarchy-clipboard-paste-text +++ b/bin/omarchy-clipboard-paste-text @@ -11,7 +11,7 @@ copy_only=false history_index="" text="" -while [[ $# -gt 0 ]]; do +while (( $# > 0 )); do case "$1" in --shift-insert) use_shift_insert=true diff --git a/bin/omarchy-default-browser b/bin/omarchy-default-browser index 1b7baf07..f53ccf9a 100755 --- a/bin/omarchy-default-browser +++ b/bin/omarchy-default-browser @@ -5,7 +5,7 @@ # omarchy:examples=omarchy default browser firefox | omarchy default browser brave if (($# == 0)); then - case "$(xdg-settings get default-web-browser)" in + case "$(env -u BROWSER xdg-settings get default-web-browser)" in chromium.desktop) echo "chromium" ;; google-chrome.desktop) echo "chrome" ;; brave-browser.desktop) echo "brave" ;; @@ -13,7 +13,7 @@ if (($# == 0)); then microsoft-edge.desktop) echo "edge" ;; firefox.desktop) echo "firefox" ;; zen.desktop) echo "zen" ;; - *) xdg-settings get default-web-browser ;; + *) env -u BROWSER xdg-settings get default-web-browser ;; esac exit 0 fi @@ -32,9 +32,6 @@ zen) desktop_id="zen.desktop"; name="Zen"; glyph=󰖟 ;; ;; esac -xdg-settings set default-web-browser "$desktop_id" -xdg-mime default "$desktop_id" x-scheme-handler/http -xdg-mime default "$desktop_id" x-scheme-handler/https -xdg-mime default "$desktop_id" text/html +env -u BROWSER xdg-settings set default-web-browser "$desktop_id" || exit 1 omarchy-notification-send -g $glyph "$name is now the default browser" diff --git a/bin/omarchy-dev-link b/bin/omarchy-dev-link index 78076634..00fba9d8 100755 --- a/bin/omarchy-dev-link +++ b/bin/omarchy-dev-link @@ -2,8 +2,8 @@ # omarchy:summary=Point Omarchy at a local checkout after reboot # omarchy:group=dev -# omarchy:args= -# omarchy:examples=omarchy dev link ~/Work/omarchy/omarchy-installer +# omarchy:args= [--no-reboot] +# omarchy:examples=omarchy dev link ~/omarchy set -euo pipefail @@ -12,9 +12,11 @@ if (( EUID == 0 )); then exit 1 fi -if (( $# != 1 )) || [[ $1 == "-h" || $1 == "--help" ]]; then +prompt_reboot=1 + +if (( $# < 1 || $# > 2 )) || [[ $1 == "-h" || $1 == "--help" ]]; then cat < +Usage: omarchy dev link [--no-reboot] Writes /etc/omarchy.conf so OMARCHY_PATH resolves to after reboot. This intentionally does not rewrite the running Hyprland, @@ -25,10 +27,21 @@ themes/, applications/, config/. Files installed at fixed system paths (/etc/, /usr/lib/systemd/, udev rule bodies, /etc/skel after user creation, /usr/share/plymouth) are NOT covered — for those, use omarchy-dev-pkg-test to build and install the package from the checkout. + +Use --no-reboot when another command will handle the reboot prompt. USAGE exit 0 fi +if (( $# == 2 )); then + if [[ $2 == "--no-reboot" ]]; then + prompt_reboot=0 + else + echo "Usage: omarchy dev link [--no-reboot]" >&2 + exit 1 + fi +fi + omarchy_conf_quote() { local value="$1" value=${value//\\/\\\\} @@ -58,6 +71,6 @@ done echo "Pointed Omarchy at $target" echo -if gum confirm "Reboot now to activate?"; then +if (( prompt_reboot )) && gum confirm "Reboot now to activate?"; then omarchy-system-reboot fi diff --git a/bin/omarchy-dev-unlink b/bin/omarchy-dev-unlink index 5c50855c..020774f6 100755 --- a/bin/omarchy-dev-unlink +++ b/bin/omarchy-dev-unlink @@ -2,6 +2,7 @@ # omarchy:summary=Restore Omarchy to the package install after reboot # omarchy:group=dev +# omarchy:args=[--no-reboot] set -euo pipefail @@ -10,16 +11,36 @@ if (( EUID == 0 )); then exit 1 fi -if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then - cat < 1 )); then + echo "Usage: omarchy dev unlink [--no-reboot]" >&2 + exit 1 +fi + +case "${1:-}" in + "") + ;; + --no-reboot) + prompt_reboot=0 + ;; + -h|--help) + cat <&2 + exit 1 + ;; +esac default_target="/usr/share/omarchy" @@ -28,6 +49,6 @@ printf 'export OMARCHY_PATH="%s"\n' "$default_target" | sudo tee /etc/omarchy.co echo "Pointed Omarchy at $default_target" echo -if gum confirm "Reboot now to activate?"; then +if (( prompt_reboot )) && gum confirm "Reboot now to activate?"; then omarchy-system-reboot fi diff --git a/bin/omarchy-finalize-user b/bin/omarchy-finalize-user index dcf72094..41d7a9ae 100755 --- a/bin/omarchy-finalize-user +++ b/bin/omarchy-finalize-user @@ -102,7 +102,7 @@ done source "$OMARCHY_INSTALL/user/all.sh" omarchy-refresh-applications -xdg-settings set default-web-browser chromium.desktop +env -u BROWSER xdg-settings set default-web-browser chromium.desktop xdg-mime default HEY.desktop x-scheme-handler/mailto if (( first_install )); then diff --git a/bin/omarchy-first-run b/bin/omarchy-first-run index b95849d3..a771a9c1 100755 --- a/bin/omarchy-first-run +++ b/bin/omarchy-first-run @@ -53,37 +53,6 @@ log_first_run() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >>"$FIRST_RUN_LOG" } -notification_server_ready() { - if omarchy-cmd-present gdbus; then - gdbus call --session \ - --dest org.freedesktop.Notifications \ - --object-path /org/freedesktop/Notifications \ - --method org.freedesktop.Notifications.GetServerInformation >/dev/null 2>&1 - elif omarchy-cmd-present busctl; then - busctl --user call \ - org.freedesktop.Notifications \ - /org/freedesktop/Notifications \ - org.freedesktop.Notifications \ - GetServerInformation >/dev/null 2>&1 - else - return 0 - fi -} - -wait_for_notifications() { - omarchy-cmd-present omarchy-shell || return 0 - - for _ in {1..100}; do - if omarchy-shell notifications ping >/dev/null 2>&1 && notification_server_ready; then - return 0 - fi - sleep 0.1 - done - - log_first_run "Timed out waiting for notification service; continuing" - return 0 -} - run_first_run_step() { local name="$1" shift @@ -98,14 +67,10 @@ run_first_run_step() { fi } -wait_for_notifications - -run_first_run_step "enable migration notification watcher" \ - systemctl --user enable --now omarchy-update-user-notify.path -run_first_run_step "notify about pending migrations" omarchy-migrate-notify - run_first_run_step "install Voxtype post-update hook" \ omarchy-hook-install post-update "$OMARCHY_PATH/install/user/first-run/install-voxtype.hook" +run_first_run_step "install fingerprint setup post-update hook" \ + omarchy-hook-install post-update "$OMARCHY_PATH/install/user/first-run/setup-fingerprint.hook" run_first_run_step "enable user systemd units" \ bash "$OMARCHY_PATH/install/user/first-run/enable-user-units.sh" @@ -113,8 +78,10 @@ run_first_run_step "set GNOME theme" \ bash "$OMARCHY_PATH/install/user/first-run/gnome-theme.sh" run_first_run_step "set GTK primary paste" \ bash "$OMARCHY_PATH/install/user/first-run/gtk-primary-paste.sh" +run_first_run_step "apply speaker tuning" \ + bash "$OMARCHY_PATH/install/user/first-run/audio-tuning.sh" -wait_for_notifications +omarchy-notification-wait || log_first_run "Timed out waiting for notification service; continuing" run_first_run_step "show welcome notification" \ bash "$OMARCHY_PATH/install/user/first-run/welcome.sh" # The first-run notification scripts register action callbacks in background diff --git a/bin/omarchy-font-set b/bin/omarchy-font-set index 59c22ca5..7c80fbc4 100755 --- a/bin/omarchy-font-set +++ b/bin/omarchy-font-set @@ -9,7 +9,6 @@ usage() { } font_name="${1:-}" -omarchy_root="${OMARCHY_PATH:-/usr/share/omarchy}" case "$font_name" in -h|--help) @@ -46,36 +45,25 @@ if [[ -f ~/.config/foot/foot.ini ]]; then fi # fontconfig is the canonical source of truth — the omarchy shell, Qt apps, -# and anything resolving "monospace" all read from here. The shipped default -# is package-owned; create a user override only when the user changes fonts. +# and anything resolving "monospace" all read from here. This file is loaded +# after the package-owned default, and prepend_first puts the chosen family at +# the head of the list so it wins over the family that default prefers. fontconfig_file="$HOME/.config/fontconfig/fonts.conf" -if [[ ! -f $fontconfig_file ]]; then - fontconfig_default="$omarchy_root/default/fontconfig/conf.avail/50-omarchy.conf" - if [[ ! -f $fontconfig_default ]]; then - echo "Default fontconfig file not found: $fontconfig_default" >&2 - exit 1 - fi - mkdir -p "$(dirname "$fontconfig_file")" - cp "$fontconfig_default" "$fontconfig_file" -fi - -# We own the markup in 50-omarchy.conf, so the monospace block is predictable: -# the FAMILY immediately after monospace is -# the family we're replacing. -tmp=$(mktemp) -if ! awk -v new_font="$font_name" ' - in_mono && /[^<]*<\/string>/ { - sub(/[^<]*<\/string>/, "" new_font "") - in_mono = 0 - } - /monospace<\/string>/ { in_mono = 1 } - { print } -' "$fontconfig_file" >"$tmp"; then - rm -f "$tmp" - echo "Failed to update $fontconfig_file" >&2 - exit 1 -fi -mv "$tmp" "$fontconfig_file" +mkdir -p "$(dirname "$fontconfig_file")" +cat >"$fontconfig_file" < + + + + + monospace + + + $font_name + + + +XML omarchy-restart-shell diff --git a/bin/omarchy-hw-clamshell b/bin/omarchy-hw-clamshell index 0792e6ec..238f6627 100755 --- a/bin/omarchy-hw-clamshell +++ b/bin/omarchy-hw-clamshell @@ -3,14 +3,5 @@ # omarchy:summary=Returns true when clamshell mode is active # omarchy:hidden=true -lid_closed=false - -for state in /proc/acpi/button/lid/*/state; do - [[ -r $state ]] || continue - if [[ $(< "$state") == *"closed"* ]]; then - lid_closed=true - break - fi -done - -[[ $lid_closed == "true" ]] && omarchy-hw-external-monitors +# Clamshell = lid closed while driving one or more external monitors. +omarchy-hw-laptop-closed && omarchy-hw-external-monitors diff --git a/bin/omarchy-hw-fingerprint b/bin/omarchy-hw-fingerprint new file mode 100755 index 00000000..c5f119d9 --- /dev/null +++ b/bin/omarchy-hw-fingerprint @@ -0,0 +1,43 @@ +#!/bin/bash + +# omarchy:summary=Returns true when a fingerprint reader is present +# omarchy:hidden=true + +# Detect straight from sysfs so this works before fprintd/usbutils are +# installed (the fingerprint setup pulls those in). USB vendor IDs listed here +# ship fingerprint readers; multi-purpose vendors (e.g. Elan/STMicro, which +# also make USB touchscreens) are left out to avoid nagging laptops with no +# reader — those still match on the product string below when present. +fingerprint_vendors=" 27c6 138a 06cb 08ff 1c7a 147e " + +# libfprint drives every reader it supports from userspace over libusb, so a +# real reader sits there with no kernel driver bound to any of its interfaces. +# The other things these vendors build — Synaptics webcam bridges (usbio-bridge +# on the Dell XPS 14), touchpads and touchscreens (usbhid), cameras (uvcvideo) +# — all bind one. Only the vendor-ID guess needs this; a device that names +# itself a fingerprint reader is trusted outright. +has_kernel_driver() { + local intf + for intf in "$1"/*:*; do + [[ -e $intf/driver ]] && return 0 + done + return 1 +} + +for dev in /sys/bus/usb/devices/*; do + # The device's own product descriptor usually names it, e.g. "Goodix + # Fingerprint USB Device" — driver-independent and vendor-agnostic. + if [[ -r $dev/product ]]; then + product=$(<"$dev/product") + product=${product,,} + [[ $product == *fingerprint* || $product == *biometric* ]] && exit 0 + fi + + if [[ -r $dev/idVendor ]]; then + vendor=$(<"$dev/idVendor") + [[ $fingerprint_vendors == *" $vendor "* ]] && + ! has_kernel_driver "$dev" && exit 0 + fi +done + +exit 1 diff --git a/bin/omarchy-hw-laptop-closed b/bin/omarchy-hw-laptop-closed new file mode 100755 index 00000000..8e03f965 --- /dev/null +++ b/bin/omarchy-hw-laptop-closed @@ -0,0 +1,11 @@ +#!/bin/bash + +# omarchy:summary=Returns true when the laptop lid is closed +# omarchy:hidden=true + +for state in /proc/acpi/button/lid/*/state; do + [[ -r $state ]] || continue + [[ $(< "$state") == *"closed"* ]] && exit 0 +done + +exit 1 diff --git a/bin/omarchy-hw-webcam b/bin/omarchy-hw-webcam new file mode 100755 index 00000000..4874dd77 --- /dev/null +++ b/bin/omarchy-hw-webcam @@ -0,0 +1,5 @@ +#!/bin/bash + +# omarchy:summary=Check whether a webcam is available + +v4l2-ctl --list-devices 2>/dev/null | grep -q '^[[:space:]]*/dev/video' diff --git a/bin/omarchy-hyprland-monitor-clamshell b/bin/omarchy-hyprland-monitor-clamshell index df40ce3d..2efbc36b 100755 --- a/bin/omarchy-hyprland-monitor-clamshell +++ b/bin/omarchy-hyprland-monitor-clamshell @@ -8,7 +8,7 @@ CLAMSHELL_FLAG="$TOGGLES_DIR/internal-monitor-clamshell.lua" MANUAL_DISABLE_FLAG="$TOGGLES_DIR/internal-monitor-disable.lua" SCALE_STATE="$TOGGLES_DIR/internal-monitor-scale" -INTERNAL=$(hyprctl monitors all -j | jq -r '.[] | select(.name | test("^(eDP|LVDS|DSI)-")).name' | head -n 1) +INTERNAL=$(omarchy-hyprland-monitor-laptop) valid_scale() { [[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]] @@ -30,13 +30,24 @@ configured_monitor_scale() { local monitor_lua="$HOME/.config/hypr/monitors.lua" [[ -f $monitor_lua ]] || return 0 local scale - scale=$(sed -n 's/^local omarchy_monitor_scale = //p' "$monitor_lua" | head -1) + scale=$(configured_internal_monitor_value scale) + if [[ -z $scale ]]; then + scale=$(sed -n 's/^local omarchy_monitor_scale = //p' "$monitor_lua" | head -1) + fi if [[ -z $scale ]]; then scale=$(sed -nE 's/^hl\.monitor\(\{ output = "", mode = "preferred", position = "auto", scale = ([^ ]+) \}\)/\1/p' "$monitor_lua" | head -1) fi echo "$scale" } +configured_internal_monitor_value() { + local key="$1" + local monitor_lua="$HOME/.config/hypr/monitors.lua" + [[ -n $INTERNAL && -f $monitor_lua ]] || return 0 + + sed -nE '/^hl\.monitor\(\{.*output = "'"$INTERNAL"'".*\}\)/s/.*'"$key"' = "?([^", }]+)"?.*/\1/p' "$monitor_lua" | head -1 +} + current_internal_scale() { [[ -n $INTERNAL ]] || return 0 hyprctl monitors all -j | jq -r --arg internal "$INTERNAL" '.[] | select(.name == $internal and .disabled != true) | .scale' | head -1 @@ -75,11 +86,24 @@ read_monitor_scale() { echo 2 } +read_monitor_position() { + local position + position=$(configured_internal_monitor_value position) + if [[ $position =~ ^[-[:alnum:]_.+]+$ ]]; then + echo "$position" + return + fi + + echo auto +} + enable_internal_output() { [[ -n $INTERNAL ]] || return 0 local scale="${1:-}" + local position [[ -n $scale ]] || scale=$(read_monitor_scale) - hyprctl eval "hl.monitor({ output = \"$INTERNAL\", mode = \"preferred\", position = \"auto\", scale = $scale })" >/dev/null 2>&1 || true + position=$(read_monitor_position) + hyprctl eval "hl.monitor({ output = \"$INTERNAL\", mode = \"preferred\", position = \"$position\", scale = $scale })" >/dev/null 2>&1 || true } sync_internal_scale() { diff --git a/bin/omarchy-hyprland-monitor-internal b/bin/omarchy-hyprland-monitor-internal index 9916f95c..836073ee 100755 --- a/bin/omarchy-hyprland-monitor-internal +++ b/bin/omarchy-hyprland-monitor-internal @@ -7,8 +7,7 @@ TOGGLE="internal-monitor-disable" TOGGLE_FLAG="$HOME/.local/state/omarchy/toggles/hypr/$TOGGLE.lua" MIRROR_TOGGLE="internal-monitor-mirror" -# Get internal monitor name dynamically, including disabled outputs. -INTERNAL=$(hyprctl monitors all -j | jq -r '.[] | select(.name | test("^(eDP|LVDS|DSI)-")).name' | head -n 1) +INTERNAL=$(omarchy-hyprland-monitor-laptop) wake() { hyprctl dispatch 'hl.dsp.dpms({ action = "enable" })' >/dev/null 2>&1 || true diff --git a/bin/omarchy-hyprland-monitor-internal-mirror b/bin/omarchy-hyprland-monitor-internal-mirror index 57531d25..d5213d4c 100755 --- a/bin/omarchy-hyprland-monitor-internal-mirror +++ b/bin/omarchy-hyprland-monitor-internal-mirror @@ -7,9 +7,8 @@ TOGGLE="internal-monitor-mirror" TOGGLE_FLAG="$HOME/.local/state/omarchy/toggles/hypr/$TOGGLE.lua" DISABLE_TOGGLE="internal-monitor-disable" -# Get names dynamically -INTERNAL=$(hyprctl monitors -j | jq -r '.[] | select(.name | test("^(eDP|LVDS|DSI)-")).name' | head -n 1) -# Get the first available external monitor +INTERNAL=$(omarchy-hyprland-monitor-laptop) +# The first active external monitor EXTERNAL=$(hyprctl monitors -j | jq -r '.[] | select(.name | test("^(eDP|LVDS|DSI)-") | not).name' | head -n 1) on() { diff --git a/bin/omarchy-hyprland-monitor-laptop b/bin/omarchy-hyprland-monitor-laptop new file mode 100755 index 00000000..cc0a254f --- /dev/null +++ b/bin/omarchy-hyprland-monitor-laptop @@ -0,0 +1,5 @@ +#!/bin/bash + +# omarchy:summary=Print the name of the built-in laptop display, including disabled outputs. + +hyprctl monitors all -j | jq -r '.[] | select(.name | test("^(eDP|LVDS|DSI)-")).name' | head -n 1 diff --git a/bin/omarchy-hyprland-monitor-scaling b/bin/omarchy-hyprland-monitor-scaling index f90c078e..24060ecc 100755 --- a/bin/omarchy-hyprland-monitor-scaling +++ b/bin/omarchy-hyprland-monitor-scaling @@ -1,7 +1,7 @@ #!/bin/bash # omarchy:summary=Show, set, or adjust focused Hyprland monitor scaling -# omarchy:args=[up|down|1|1.25|1.6|2|3|4] +# omarchy:args=[up|down|SCALE] # omarchy:examples=omarchy hyprland monitor scaling | omarchy hyprland monitor scaling 1.6 | omarchy hyprland monitor scaling up | omarchy hyprland monitor scaling down SCALES=(1 1.25 1.6 2 3 4) @@ -9,7 +9,7 @@ STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy" SCALE_LOG="$STATE_DIR/monitor-scaling.log" usage() { - echo "Usage: omarchy-hyprland-monitor-scaling [up|down|1|1.25|1.6|2|3|4]" + echo "Usage: omarchy-hyprland-monitor-scaling [up|down|SCALE]" } focused_monitor_scale() { @@ -54,8 +54,7 @@ audit_scale_change() { # Hyprland only accepts scales where the mode divides into whole logical # pixels (in 1/120 steps), so clean scales are divisors of gcd(w*120, h*120). -# Round the requested scale up to the nearest one, so 3x on a 1280x800 QEMU -# display becomes 3.2x instead of a red error overlay. +# Round the requested scale up to the nearest clean value. clean_scale() { awk -v scale="$1" -v width="$2" -v height="$3" ' function gcd(a, b, t) { while (b) { t = a % b; a = b; b = t } return a } @@ -68,6 +67,10 @@ clean_scale() { }' } +normalize_scale() { + awk 'NR == 1 { printf "%g\n", $0 }' +} + set_scale() { local requested_scale="$1" local requested="${2:-$requested_scale}" @@ -100,21 +103,50 @@ set_scale() { scale_from_current() { local direction="${1:-}" + local width="${2:-}" + local height="${3:-}" - awk -v direction="$direction" -v list="${SCALES[*]}" ' + awk -v direction="$direction" -v list="${SCALES[*]}" -v width="$width" -v height="$height" ' + function gcd(a, b, t) { while (b) { t = a % b; a = b; b = t } return a } + function clean(scale, g, k) { + g = gcd(width * 120, height * 120) + k = int(scale * 120 + 0.5) + if (k > g) k = g + while (g % k != 0) k++ + return k / 120 + } NR == 1 { scale = $0; found = 1 } END { if (!found) exit 1 - n = split(list, scales, " ") + preset_count = split(list, presets, " ") + for (i = 1; i <= preset_count; i++) { + effective = clean(presets[i]) + key = sprintf("%.8f", effective) + distance = presets[i] - effective + if (distance < 0) distance = -distance - # Snap to the nearest preset first. Hyprland reports scales as floating - # point values, so a scale set to 3 can come back as 3.0000000000000004. - # Without snapping, the directional comparisons below can misidentify - # the current preset and refuse to step down (or up) any further. + # Multiple presets can collapse to the same clean scale. Keep only the + # closest label so stepping always moves to a distinct effective value. + if (!(key in effective_index)) { + effective_index[key] = ++n + effective_scales[n] = effective + scales[n] = presets[i] + distances[n] = distance + } else { + idx = effective_index[key] + if (distance < distances[idx]) { + scales[idx] = presets[i] + distances[idx] = distance + } + } + } + + # Snap to the nearest effective scale first. Hyprland reports floating + # point values, so exact comparisons can otherwise get stuck. best = 1; best_diff = 1e9 for (i = 1; i <= n; i++) { - diff = scale - scales[i]; if (diff < 0) diff = -diff + diff = scale - effective_scales[i]; if (diff < 0) diff = -diff if (diff < best_diff) { best_diff = diff; best = i } } @@ -130,22 +162,31 @@ scale_from_current() { case "${1:-}" in "") - focused_monitor_scale | scale_from_current + focused_monitor_scale | normalize_scale ;; -h | --help) usage ;; up) - set_scale "$(focused_monitor_scale | scale_from_current next)" "up" + monitor_info=$(hyprctl monitors -j | jq -e -c '.[] | select(.focused == true)') + set_scale "$(echo "$monitor_info" | jq -r '.scale' | scale_from_current next \ + "$(echo "$monitor_info" | jq -r '.width')" "$(echo "$monitor_info" | jq -r '.height')")" "up" ;; down) - set_scale "$(focused_monitor_scale | scale_from_current previous)" "down" + monitor_info=$(hyprctl monitors -j | jq -e -c '.[] | select(.focused == true)') + set_scale "$(echo "$monitor_info" | jq -r '.scale' | scale_from_current previous \ + "$(echo "$monitor_info" | jq -r '.width')" "$(echo "$monitor_info" | jq -r '.height')")" "down" ;; 1 | 1.25 | 1.6 | 2 | 3 | 4) set_scale "$1" "$1" ;; *) - usage >&2 - exit 1 + if [[ $1 =~ ^[0-9]+([.][0-9]+)?$ ]] && + awk -v scale="$1" 'BEGIN { exit !(scale >= 1 && scale <= 4) }'; then + set_scale "$1" "$1" + else + usage >&2 + exit 1 + fi ;; esac diff --git a/bin/omarchy-hyprland-workspace-layout-toggle b/bin/omarchy-hyprland-workspace-layout-toggle index 28f76337..29b0c10e 100755 --- a/bin/omarchy-hyprland-workspace-layout-toggle +++ b/bin/omarchy-hyprland-workspace-layout-toggle @@ -3,13 +3,19 @@ # omarchy:summary=Toggle the layout on the current active workspace between dwindle and scrolling ACTIVE_WORKSPACE=$(hyprctl activeworkspace -j | jq -r '.id') +[[ $ACTIVE_WORKSPACE =~ ^-?[0-9]+$ ]] || exit 1 CURRENT_LAYOUT=$(hyprctl activeworkspace -j | jq -r '.tiledLayout') +LAYOUTS_DIR="$HOME/.local/state/omarchy/workspace-layouts" +LAYOUT_FILE="$LAYOUTS_DIR/$ACTIVE_WORKSPACE.lua" case "$CURRENT_LAYOUT" in dwindle) NEW_LAYOUT=scrolling ;; *) NEW_LAYOUT=dwindle ;; esac +mkdir -p "$LAYOUTS_DIR" +printf 'hl.workspace_rule({ workspace = "%s", layout = "%s" })\n' "$ACTIVE_WORKSPACE" "$NEW_LAYOUT" >"$LAYOUT_FILE" + hyprctl eval "hl.workspace_rule({ workspace = \"$ACTIVE_WORKSPACE\", layout = \"$NEW_LAYOUT\" })" >/dev/null 2>&1 || \ - hyprctl keyword workspace $ACTIVE_WORKSPACE, layout:$NEW_LAYOUT + hyprctl keyword workspace "$ACTIVE_WORKSPACE, layout:$NEW_LAYOUT" omarchy-notification-send -g 󱂬 "Workspace layout set to $NEW_LAYOUT" diff --git a/bin/omarchy-install-editor-emacs b/bin/omarchy-install-editor-emacs new file mode 100755 index 00000000..d932d04d --- /dev/null +++ b/bin/omarchy-install-editor-emacs @@ -0,0 +1,9 @@ +#!/bin/bash + +# omarchy:summary=Install Emacs with Omarchy theme and font integration via the omarchy-emacs AUR package + +echo "Installing Emacs..." +omarchy-pkg-aur-add omarchy-emacs && omarchy-install-emacs + +# emacsclient opens a frame on the running daemon, not a second Emacs +setsid gtk-launch emacsclient diff --git a/bin/omarchy-launch-browser b/bin/omarchy-launch-browser index 567cffad..8f365bd2 100755 --- a/bin/omarchy-launch-browser +++ b/bin/omarchy-launch-browser @@ -3,7 +3,7 @@ # omarchy:summary=Launch the default browser as determined by xdg-settings. # omarchy:args=[url] -default_browser=$(xdg-settings get default-web-browser) +default_browser=$(env -u BROWSER xdg-settings get default-web-browser) browser_exec=$(sed -n 's/^Exec=\([^ ]*\).*/\1/p' {~/.local,~/.nix-profile,/usr}/share/applications/$default_browser 2>/dev/null | head -1) if $browser_exec --help 2>/dev/null | grep -q MOZ_LOG; then diff --git a/bin/omarchy-launch-screensaver b/bin/omarchy-launch-screensaver index 3f8b82f8..77122efd 100755 --- a/bin/omarchy-launch-screensaver +++ b/bin/omarchy-launch-screensaver @@ -17,6 +17,14 @@ fi focused=$(omarchy-hyprland-monitor-focused) terminal=$(xdg-terminal-exec --print-id) +case $terminal in +*Alacritty* | *ghostty* | *foot* | *kitty*) ;; +*) + omarchy-notification-send -g ✋ "Screensaver only runs in Alacritty, Foot, Ghostty, or Kitty" + exit 1 + ;; +esac + hypr_focus_monitor() { hyprctl dispatch "hl.dsp.focus({ monitor = \"$1\" })" >/dev/null 2>&1 || hyprctl dispatch focusmonitor "$1" >/dev/null } @@ -28,6 +36,23 @@ hypr_exec() { hyprctl dispatch "hl.dsp.exec_cmd([[$command]])" >/dev/null 2>&1 || hyprctl dispatch exec -- bash -lc "$command" >/dev/null } +SOCKET="$XDG_RUNTIME_DIR/hypr/$HYPRLAND_INSTANCE_SIGNATURE/.socket2.sock" + +# Open Hyprland's event stream before spawning anything, so a terminal that maps +# quickly can't emit its openwindow event before we are listening for it. +exec {events}< <(socat -U - "UNIX-CONNECT:$SOCKET") + +# hypr_exec is async and a new window maps on whatever monitor is focused at that +# moment. Block until this monitor's screensaver actually opens before moving +# focus on -- otherwise slow-starting terminals all pile onto the last monitor. +# The deadline is a safety net in case the window never appears. +wait_for_screensaver_window() { + local line deadline=$((SECONDS + 5)) + while ((SECONDS < deadline)) && IFS= read -r -t $((deadline - SECONDS)) -u "$events" line; do + [[ $line == openwindow\>\>*,org.omarchy.screensaver,* ]] && return 0 + done +} + for m in $(hyprctl monitors -j | jq -r '.[] | .name'); do hypr_focus_monitor "$m" @@ -44,10 +69,9 @@ for m in $(hyprctl monitors -j | jq -r '.[] | .name'); do *kitty*) hypr_exec kitty --class=org.omarchy.screensaver --override font_size=18 --override window_padding_width=0 -e omarchy-screensaver ;; - *) - omarchy-notification-send -g ✋ "Screensaver only runs in Alacritty, Foot, Ghostty, or Kitty" - ;; esac + + wait_for_screensaver_window done hypr_focus_monitor "$focused" diff --git a/bin/omarchy-menu-keybindings b/bin/omarchy-menu-keybindings index 988ab602..07ad3f80 100755 --- a/bin/omarchy-menu-keybindings +++ b/bin/omarchy-menu-keybindings @@ -45,13 +45,9 @@ parse_keycodes() { { if (match($0, /code:([0-9]+)/, match_parts)) { code = match_parts[1] - if (code == "201") { - sub(/SUPER SHIFT,code:201/, ",COPILOT KEY") - } else { - symbol = keycode_symbol[code] - if (symbol == "") symbol = "code:" code - sub("code:" code, symbol) - } + symbol = keycode_symbol[code] + if (symbol == "") symbol = "code:" code + sub("code:" code, symbol) } else if (match($0, /mouse:([0-9]+)/, match_parts)) { code = match_parts[1] symbol = mouse_symbol[code] @@ -304,6 +300,9 @@ dynamic_bindings() { [[ -z $description && $dispatcher == "__lua" ]] && continue + # The Copilot key just duplicates an existing binding, so keep it hidden + [[ $key == "code:201" ]] && continue + case "$key" in comma) key="COMMA" ;; period) key="PERIOD" ;; @@ -419,6 +418,7 @@ prioritize_entries() { if (match(line, /Toggle workspace gaps/)) prio = 39 if (match(line, /Toggle nightlight/)) prio = 40 if (match(line, /Toggle locking/)) prio = 41 + if (match(line, /Jump to waiting Tmux pane/)) prio = 42 if (match(line, /group/)) prio = 94 if (match(line, /Scroll active workspace/)) prio = 95 if (match(line, /Cycle to/)) prio = 96 @@ -456,7 +456,7 @@ output_binding_records_uncached() { keybindings_cache_key() { { - printf 'v6\n' + printf 'v8\n' hyprctl devices 2>/dev/null | grep -F 'active keymap:' hyprctl binds 2>/dev/null } | sha256sum | awk '{ print $1 }' diff --git a/bin/omarchy-migrate b/bin/omarchy-migrate index fc801e35..056a578d 100755 --- a/bin/omarchy-migrate +++ b/bin/omarchy-migrate @@ -96,6 +96,7 @@ while IFS=$'\t' read -r name file marker; do fi done < <(migration_entries) -# Clear notifications queued while an update was installing migrations. The -# substring matches both the current and legacy notification titles. +# Clear a login-time notification the user left sitting there and then resolved +# by running migrations some other way. The substring matches both the current +# and legacy notification titles. omarchy-notification-dismiss "Omarchy Migrations" >/dev/null 2>&1 || true diff --git a/bin/omarchy-migrate-notify b/bin/omarchy-migrate-notify index 8f7935b8..1f9fb541 100755 --- a/bin/omarchy-migrate-notify +++ b/bin/omarchy-migrate-notify @@ -15,11 +15,17 @@ fi notify_command=$(printf 'if [[ -n $(omarchy-notification-send -u critical -g  "Pending Omarchy Migrations" %q -a) ]]; then omarchy-launch-floating-terminal-with-presentation omarchy-migrate; fi' "$message") -if omarchy-cmd-present systemd-run; then - unit="omarchy-migrations-notification-$(date +%Y%m%d%H%M%S)" - systemd-run --user --scope --unit="$unit" bash -lc "$notify_command" >/dev/null 2>&1 && exit 0 -fi +# This runs from omarchy-migrate-notify.service at graphical-session.target, +# which the session can reach before the shell has claimed +# org.freedesktop.Notifications. Without the wait the toast is sent into the +# void and the user never learns about their pending migrations. +omarchy-notification-wait || true +unit="omarchy-migrations-notification-$(date +%Y%m%d%H%M%S)" +systemd-run --user --scope --unit="$unit" bash -lc "$notify_command" >/dev/null 2>&1 && exit 0 + +# Reached when there is no user manager to run the scope under, such as a +# non-graphical shell, so fall back to telling the user in the terminal. print_pending_migrations() { echo "Omarchy has pending migrations. Run omarchy-migrate in a terminal to apply them:" while IFS= read -r migration; do diff --git a/bin/omarchy-mise-install b/bin/omarchy-mise-install index bf98dd9f..2140fc72 100755 --- a/bin/omarchy-mise-install +++ b/bin/omarchy-mise-install @@ -17,7 +17,7 @@ mkdir -p "$HOME/.local/bin" cat >"$HOME/.local/bin/$command" </dev/null || echo omarchy-hyprland-monitor-scaling 2>/dev/null || echo -printf '%s\n' "$monitors_json" | jq -c '[.[] | {name, enabled:(.disabled != true), focused:(.focused == true)}]' +printf '%s\n' "$monitors_json" | jq -c \ + '[.[] | {name, enabled:(.disabled != true), focused:(.focused == true), width, height}]' diff --git a/bin/omarchy-notification-wait b/bin/omarchy-notification-wait new file mode 100755 index 00000000..609dc326 --- /dev/null +++ b/bin/omarchy-notification-wait @@ -0,0 +1,30 @@ +#!/bin/bash + +# omarchy:summary=Wait for the desktop notification server to accept notifications +# omarchy:args=[timeout-seconds] +# omarchy:hidden=true + +set -uo pipefail + +timeout=${1:-10} + +notification_server_ready() { + busctl --user call \ + org.freedesktop.Notifications \ + /org/freedesktop/Notifications \ + org.freedesktop.Notifications \ + GetServerInformation >/dev/null 2>&1 +} + +# The shell has to be up to serve the IPC, and it has to have claimed the +# notification bus name before notify-send has anywhere to deliver. +attempts=$((timeout * 10)) +while (( attempts > 0 )); do + if omarchy-shell notifications ping >/dev/null 2>&1 && notification_server_ready; then + exit 0 + fi + attempts=$((attempts - 1)) + sleep 0.1 +done + +exit 1 diff --git a/bin/omarchy-osd b/bin/omarchy-osd index 08debdff..99442989 100755 --- a/bin/omarchy-osd +++ b/bin/omarchy-osd @@ -1,8 +1,8 @@ #!/bin/bash # omarchy:summary=Show the Omarchy Quickshell on-screen display -# omarchy:args=[-i|--icon ] [-m|--message ] [-p|--progress <0-100>] [-d|--duration ] [-f|--fit] -# omarchy:examples=omarchy osd -i brightness -p 50 | omarchy osd -m "Hello" | omarchy osd -m "Done" --fit +# omarchy:args=[-i|--icon ] [-m|--message ] [-p|--progress <0-100>] [-d|--duration ] +# omarchy:examples=omarchy osd -i brightness -p 50 | omarchy osd -m "Hello" set -euo pipefail @@ -12,7 +12,6 @@ progress="" progress_text="" max="100" duration="" -fit=0 while (($#)); do case $1 in @@ -20,7 +19,6 @@ while (($#)); do -m|--message) message="${2:-}"; shift 2 ;; -p|--progress) progress="${2:-}"; shift 2 ;; -d|--duration) duration="${2:-}"; shift 2 ;; - -f|--fit) fit=1; shift ;; -h|--help) omarchy osd --help; exit 0 ;; *) echo "Unknown OSD option: $1" >&2; exit 1 ;; esac @@ -37,7 +35,6 @@ payload=$(jq -cn \ --arg progressText "$progress_text" \ --arg max "$max" \ --arg duration "$duration" \ - --argjson fit "$fit" \ - '{icon:$icon,message:$message,value:$value,progressText:$progressText,max:$max,duration:$duration,fit:$fit}') + '{icon:$icon,message:$message,value:$value,progressText:$progressText,max:$max,duration:$duration}') omarchy-shell -q osd show "$payload" diff --git a/bin/omarchy-plugin b/bin/omarchy-plugin index 9955ce55..c7266fcb 100755 --- a/bin/omarchy-plugin +++ b/bin/omarchy-plugin @@ -151,7 +151,7 @@ plugin_enabled() { [[ -n $id ]] || fail "plugin id is required" shift 2 - if [[ $enabled != "true" && $# -gt 0 ]]; then + if [[ $enabled != "true" ]] && (( $# > 0 )); then fail "disable does not take placement options" fi @@ -165,7 +165,7 @@ plugin_enabled() { result=$(omarchy-shell shell setPluginEnabled "$id" "$enabled") || fail "could not update plugin; is omarchy-shell running?" [[ $result == "ok" ]] || fail "plugin '$id' is not known; run: omarchy plugin rescan" - if [[ $enabled == "true" && $# -gt 0 ]]; then + if [[ $enabled == "true" ]] && (( $# > 0 )); then omarchy-bar-plugin move "$id" "$@" echo "Enabled and moved $id" elif [[ $enabled == "true" ]]; then diff --git a/bin/omarchy-plymouth-current b/bin/omarchy-plymouth-current index 6634d3e4..ea5e570f 100755 --- a/bin/omarchy-plymouth-current +++ b/bin/omarchy-plymouth-current @@ -13,13 +13,7 @@ if cmp -s "$OMARCHY_PATH/default/plymouth/logo.png" "$installed_logo"; then fi omarchy-plymouth-list | while read -r name; do - if [[ -d ~/.config/omarchy/themes/$name ]]; then - theme_dir=~/.config/omarchy/themes/$name - else - theme_dir="$OMARCHY_PATH/themes/$name" - fi - - if cmp -s "$theme_dir/unlock.png" "$installed_logo"; then + if cmp -s "$(omarchy-theme-dir "$name")/unlock.png" "$installed_logo"; then echo "$name" exit fi diff --git a/bin/omarchy-plymouth-list b/bin/omarchy-plymouth-list index 9724d312..8841141b 100755 --- a/bin/omarchy-plymouth-list +++ b/bin/omarchy-plymouth-list @@ -7,13 +7,7 @@ find ~/.config/omarchy/themes/ -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -printf '%f\n' 2>/dev/null find "$OMARCHY_PATH/themes/" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' } | sort -u | while read -r name; do - if [[ -d ~/.config/omarchy/themes/$name ]]; then - theme_dir=~/.config/omarchy/themes/$name - else - theme_dir="$OMARCHY_PATH/themes/$name" - fi - - if [[ -f $theme_dir/preview-unlock.png ]]; then + if [[ -f $(omarchy-theme-dir "$name")/preview-unlock.png ]]; then echo "$name" fi done diff --git a/bin/omarchy-plymouth-reset b/bin/omarchy-plymouth-reset index d97bb071..797d3cb0 100755 --- a/bin/omarchy-plymouth-reset +++ b/bin/omarchy-plymouth-reset @@ -3,15 +3,5 @@ # omarchy:summary=Restore the default Omarchy Plymouth boot theme and SDDM login screen # omarchy:requires-sudo=true -theme_dir="/usr/share/plymouth/themes/omarchy" - -sudo find "$OMARCHY_PATH/default/plymouth" -maxdepth 1 -type f -exec cp -t "$theme_dir/" {} + -sudo plymouth-set-default-theme omarchy - -if omarchy-cmd-present limine-mkinitcpio; then - sudo limine-mkinitcpio -else - sudo mkinitcpio -P -fi - +omarchy-refresh-plymouth omarchy-refresh-sddm diff --git a/bin/omarchy-plymouth-set-by-theme b/bin/omarchy-plymouth-set-by-theme index 95fccffc..0d577190 100755 --- a/bin/omarchy-plymouth-set-by-theme +++ b/bin/omarchy-plymouth-set-by-theme @@ -14,11 +14,7 @@ fi theme=$1 -if [[ -d ~/.config/omarchy/themes/$theme ]]; then - theme_dir=~/.config/omarchy/themes/$theme -else - theme_dir="$OMARCHY_PATH/themes/$theme" -fi +theme_dir=$(omarchy-theme-dir "$theme") theme_color() { local key="$1" diff --git a/bin/omarchy-plymouth-switcher b/bin/omarchy-plymouth-switcher index f1a7bc8f..4c3ad2b1 100755 --- a/bin/omarchy-plymouth-switcher +++ b/bin/omarchy-plymouth-switcher @@ -10,13 +10,7 @@ mkdir -p "$preview_dir" ln -s "$OMARCHY_PATH/default/plymouth/preview-unlock.png" "$preview_dir/default.png" omarchy-plymouth-list | while read -r name; do - if [[ -d ~/.config/omarchy/themes/$name ]]; then - theme_dir=~/.config/omarchy/themes/$name - else - theme_dir="$OMARCHY_PATH/themes/$name" - fi - - ln -s "$theme_dir/preview-unlock.png" "$preview_dir/$name.png" 2>/dev/null + ln -s "$(omarchy-theme-dir "$name")/preview-unlock.png" "$preview_dir/$name.png" 2>/dev/null done current=$(omarchy-plymouth-current) diff --git a/bin/omarchy-remove-browser b/bin/omarchy-remove-browser index acba1db4..02fb6246 100755 --- a/bin/omarchy-remove-browser +++ b/bin/omarchy-remove-browser @@ -7,17 +7,14 @@ set_fallback_default_browser() { local current_browser - current_browser=$(xdg-settings get default-web-browser) + current_browser=$(env -u BROWSER xdg-settings get default-web-browser) if [[ $current_browser != $1 ]]; then return fi if omarchy-cmd-present chromium; then - xdg-settings set default-web-browser chromium.desktop - xdg-mime default chromium.desktop x-scheme-handler/http - xdg-mime default chromium.desktop x-scheme-handler/https - xdg-mime default chromium.desktop text/html + env -u BROWSER xdg-settings set default-web-browser chromium.desktop || true fi } diff --git a/bin/omarchy-remove-security-fingerprint b/bin/omarchy-remove-security-fingerprint index cdb54387..01295892 100755 --- a/bin/omarchy-remove-security-fingerprint +++ b/bin/omarchy-remove-security-fingerprint @@ -7,16 +7,16 @@ set -e remove_pam_config() { - # Remove from sudo - if grep -q pam_fprintd.so /etc/pam.d/sudo; then + # Remove from sudo (both the fingerprint module and its clamshell gate) + if grep -Eq 'pam_fprintd\.so|omarchy-hw-laptop-closed' /etc/pam.d/sudo; then echo "Removing fingerprint authentication from sudo..." - sudo sed -i '/pam_fprintd\.so/d' /etc/pam.d/sudo + sudo sed -i -e '/pam_fprintd\.so/d' -e '/omarchy-hw-laptop-closed/d' /etc/pam.d/sudo fi - # Remove from polkit - if [[ -f /etc/pam.d/polkit-1 ]] && grep -Fq 'pam_fprintd.so' /etc/pam.d/polkit-1; then + # Remove from polkit (both the fingerprint module and its clamshell gate) + if [[ -f /etc/pam.d/polkit-1 ]] && grep -Eq 'pam_fprintd\.so|omarchy-hw-laptop-closed' /etc/pam.d/polkit-1; then echo "Removing fingerprint authentication from polkit..." - sudo sed -i '/pam_fprintd\.so/d' /etc/pam.d/polkit-1 + sudo sed -i -e '/pam_fprintd\.so/d' -e '/omarchy-hw-laptop-closed/d' /etc/pam.d/polkit-1 fi } @@ -34,6 +34,6 @@ remove_pam_config remove_lock_fingerprint_pam echo "Removing fingerprint packages..." -omarchy-pkg-drop fprintd libfprint-git +omarchy-pkg-drop fprintd libfprint libfprint-git echo -e "\e[32mFingerprint authentication has been completely removed.\e[0m" diff --git a/bin/omarchy-restart-shell b/bin/omarchy-restart-shell index ee07d931..7847aac9 100755 --- a/bin/omarchy-restart-shell +++ b/bin/omarchy-restart-shell @@ -3,7 +3,12 @@ # omarchy:summary=Restart the Omarchy shell # omarchy:examples=omarchy restart shell -CONFIG_DIR="$OMARCHY_PATH/shell" +# A caller opened after dev link/unlink may disagree with the still-running +# desktop. The user manager receives Hyprland's environment at session start. +session_omarchy_path=$(systemctl --user show-environment 2>/dev/null | sed -n 's/^OMARCHY_PATH=//p' | tail -n 1) +: "${session_omarchy_path:=$OMARCHY_PATH}" + +CONFIG_DIR="$session_omarchy_path/shell" [[ -f $CONFIG_DIR/shell.qml ]] || { echo "Omarchy shell config not found: $CONFIG_DIR" >&2; exit 1; } # Allow running from outside the session (e.g. over ssh) by deriving the @@ -28,7 +33,16 @@ while timeout 5 quickshell kill -p "$CONFIG_DIR" --any-display >/dev/null 2>&1; hyprctl dispatch 'hl.dsp.exec_cmd("quickshell -n -p $OMARCHY_PATH/shell")' >/dev/null for (( attempt = 0; attempt < 20; attempt++ )); do - OMARCHY_SHELL_IPC_TIMEOUT=0.5s omarchy-shell shell ping >/dev/null 2>&1 && exit 0 + if OMARCHY_PATH="$session_omarchy_path" OMARCHY_SHELL_IPC_TIMEOUT=0.5s omarchy-shell shell ping >/dev/null 2>&1; then + # Invitation toasts (like Voxtype/fingerprint setup) die with the old + # shell, and their notify-send waiters hang forever: the dying server + # never emits NotificationClosed. A still-running omarchy-*-invitation + # unit is therefore an unanswered invitation — re-run it so its toast + # reappears on the new shell. Answered invitations have already exited + # and been collected, so the glob no longer matches them. + systemctl --user try-restart 'omarchy-*-invitation.service' 2>/dev/null || true + exit 0 + fi sleep 0.1 done diff --git a/bin/omarchy-restart-tmux b/bin/omarchy-restart-tmux index 539d5176..3a327bc3 100755 --- a/bin/omarchy-restart-tmux +++ b/bin/omarchy-restart-tmux @@ -2,6 +2,6 @@ # omarchy:summary=Restart tmux if running with the latest configuration -if pgrep -x tmux; then +if tmux has-session 2>/dev/null; then tmux source-file ~/.config/tmux/tmux.conf fi diff --git a/bin/omarchy-config-direct-boot b/bin/omarchy-setup-direct-boot similarity index 81% rename from bin/omarchy-config-direct-boot rename to bin/omarchy-setup-direct-boot index ebd618b9..898108ac 100755 --- a/bin/omarchy-config-direct-boot +++ b/bin/omarchy-setup-direct-boot @@ -13,15 +13,18 @@ if ! efibootmgr &>/dev/null; then exit 1 fi -if cat /sys/class/dmi/id/bios_vendor 2>/dev/null | grep -qi "American Megatrends"; then - echo "Error: American Megatrends firmware may not safely support custom EFI entries" >&2 - exit 1 -fi +bios_vendor=$(cat /sys/class/dmi/id/bios_vendor 2>/dev/null) -if cat /sys/class/dmi/id/bios_vendor 2>/dev/null | grep -qi "Apple"; then - echo "Error: Apple firmware uses its own boot manager" >&2 - exit 1 -fi +case "${bios_vendor,,}" in + *"american megatrends"*) + echo "Error: American Megatrends firmware may not safely support custom EFI entries" >&2 + exit 1 + ;; + *apple*) + echo "Error: Apple firmware uses its own boot manager" >&2 + exit 1 + ;; +esac existing_entry=$(efibootmgr | grep -E "^Boot[0-9A-Fa-f]+\*? Omarchy([[:space:]]|$)" | head -1) diff --git a/bin/omarchy-setup-lock b/bin/omarchy-setup-lock index 36701aae..3837ec97 100755 --- a/bin/omarchy-setup-lock +++ b/bin/omarchy-setup-lock @@ -12,8 +12,6 @@ if [[ -z $target_user && -n ${PKEXEC_UID:-} ]]; then target_user=$(getent passwd "$PKEXEC_UID" | cut -d: -f1) fi target_user=${target_user:-$USER} -target_home=$(getent passwd "$target_user" | cut -d: -f6) -target_group=$(id -gn "$target_user" 2>/dev/null || echo "$target_user") as_root() { if (( EUID == 0 )); then @@ -23,19 +21,9 @@ as_root() { fi } -tee_root() { - local path="$1" - - if (( EUID == 0 )); then - tee "$path" - else - sudo tee "$path" - fi -} - echo "Configuring lock screen password authentication..." -tee_root /etc/pam.d/omarchy-lock-password >/dev/null <<'EOF' +as_root tee /etc/pam.d/omarchy-lock-password >/dev/null <<'EOF' #%PAM-1.0 auth required pam_faillock.so preauth silent deny=10 unlock_time=120 -auth [success=2 default=ignore] pam_systemd_home.so @@ -49,7 +37,7 @@ EOF if omarchy-cmd-present fprintd-list && fprintd-list "$target_user" 2>/dev/null | grep -qi finger; then echo "Configuring lock screen fingerprint authentication..." - tee_root /etc/pam.d/omarchy-lock-fingerprint >/dev/null <<'EOF' + as_root tee /etc/pam.d/omarchy-lock-fingerprint >/dev/null <<'EOF' #%PAM-1.0 auth required pam_fprintd.so account include system-local-login @@ -58,7 +46,6 @@ else as_root rm -f /etc/pam.d/omarchy-lock-fingerprint fi - # omarchy-shell can't reach a running shell during chroot install. The echo # is just confirmation, so swallow the failure rather than letting it become # the script's exit code. diff --git a/bin/omarchy-setup-security-fingerprint b/bin/omarchy-setup-security-fingerprint index b4977c59..ffdbed34 100755 --- a/bin/omarchy-setup-security-fingerprint +++ b/bin/omarchy-setup-security-fingerprint @@ -6,32 +6,43 @@ set -e -check_fingerprint_hardware() { - # Get fingerprint devices for the user - devices=$(fprintd-list "$USER" 2>/dev/null) - - # Exit if no devices found - if [[ -z $devices ]]; then - echo -e "\e[31m\nNo fingerprint sensor detected.\e[0m" - return 1 - fi - return 0 -} - setup_pam_config() { + # A clamshell gate runs before pam_fprintd in every stack: when the lid is + # shut the reader is unreachable, so it skips fingerprint (success=1) and PAM + # drops straight to the password prompt instead of blocking on the reader + # until it times out. Lid open → fingerprint, then password as the fallback. + # + # pam_exec needs a literal absolute path (no env expansion). Point at the + # fixed /usr/bin path the omarchy package always provides, so the gate keeps + # working across package installs and dev-link — the latter overlays + # $OMARCHY_PATH trees but leaves /usr/bin untouched. + local fprintd_gate="auth [success=1 default=ignore] pam_exec.so quiet /usr/bin/omarchy-hw-laptop-closed" + # Configure sudo if ! grep -q pam_fprintd.so /etc/pam.d/sudo; then echo "Configuring sudo for fingerprint authentication..." - sudo sed -i '1i auth sufficient pam_fprintd.so' /etc/pam.d/sudo + sudo sed -i '1i auth sufficient pam_fprintd.so' /etc/pam.d/sudo + fi + if ! grep -q 'omarchy-hw-laptop-closed' /etc/pam.d/sudo; then + echo "Adding clamshell gate to sudo..." + # Insert immediately before pam_fprintd so success=1 skips exactly it. + sudo sed -i "/pam_fprintd\.so/i $fprintd_gate" /etc/pam.d/sudo fi # Configure polkit - if [[ -f /etc/pam.d/polkit-1 ]] && ! grep -q 'pam_fprintd.so' /etc/pam.d/polkit-1; then - echo "Configuring polkit for fingerprint authentication..." - sudo sed -i '1i auth sufficient pam_fprintd.so' /etc/pam.d/polkit-1 - elif [[ ! -f /etc/pam.d/polkit-1 ]]; then + if [[ -f /etc/pam.d/polkit-1 ]]; then + if ! grep -q 'pam_fprintd.so' /etc/pam.d/polkit-1; then + echo "Configuring polkit for fingerprint authentication..." + sudo sed -i '1i auth sufficient pam_fprintd.so' /etc/pam.d/polkit-1 + fi + if ! grep -q 'omarchy-hw-laptop-closed' /etc/pam.d/polkit-1; then + echo "Adding clamshell gate to polkit..." + sudo sed -i "/pam_fprintd\.so/i $fprintd_gate" /etc/pam.d/polkit-1 + fi + else echo "Creating polkit configuration with fingerprint authentication..." - sudo tee /etc/pam.d/polkit-1 >/dev/null <<'EOF' + sudo tee /etc/pam.d/polkit-1 >/dev/null </dev/null || true) - # libfprint-git provides+conflicts libfprint; pacman -S --noconfirm -# defaults the conflict prompt to N and aborts. Pre-remove the exact -# libfprint package, but not an installed provider like libfprint-git. -if [[ $installed_libfprint == "libfprint" ]]; then - sudo pacman -Rdd --noconfirm libfprint +# defaults the conflict prompt to N and aborts. Pre-remove it (deps-only, +# so an installed fprintd stays put) so stock libfprint installs cleanly. +if pacman -Q libfprint-git &>/dev/null; then + sudo pacman -Rdd --noconfirm libfprint-git fi -omarchy-pkg-add libfprint-git fprintd usbutils - -if ! check_fingerprint_hardware; then - exit 1 -fi +omarchy-pkg-add libfprint fprintd usbutils # Configure PAM setup_pam_config diff --git a/bin/omarchy-setup-security-sshd b/bin/omarchy-setup-security-sshd index 907bc021..781cd1b9 100755 --- a/bin/omarchy-setup-security-sshd +++ b/bin/omarchy-setup-security-sshd @@ -89,7 +89,7 @@ authorize_keys_from_github() { authorize_key "$key" && added=$((added + 1)) done <<<"$keys" - if [[ $added -eq 0 ]]; then + if (( added == 0 )); then echo -e "\e[31mNo valid SSH keys found for GitHub user '$username'.\e[0m" >&2 exit 1 fi diff --git a/bin/omarchy-setup-system b/bin/omarchy-setup-system index d66cbf80..a0d27d22 100755 --- a/bin/omarchy-setup-system +++ b/bin/omarchy-setup-system @@ -73,21 +73,11 @@ export PATH="$OMARCHY_PATH/bin:$PATH" source "$OMARCHY_INSTALL/helpers/logging.sh" start_install_log -run_logged "$OMARCHY_INSTALL/config/theme-system.sh" -run_logged "$OMARCHY_INSTALL/config/increase-lockout-limit.sh" -run_logged "$OMARCHY_INSTALL/config/lockscreen-pam.sh" -run_logged "$OMARCHY_INSTALL/config/fix-powerprofilesctl-shebang.sh" -run_logged "$OMARCHY_INSTALL/config/docker.sh" -run_logged "$OMARCHY_INSTALL/config/snapper.sh" -run_logged "$OMARCHY_INSTALL/config/enable-services.sh" -run_logged "$OMARCHY_INSTALL/config/firewall.sh" +source "$OMARCHY_INSTALL/config/all.sh" omarchy-setup-hardware --install-user "$install_user" -run_logged "$OMARCHY_INSTALL/login/sddm.sh" - -run_logged "$OMARCHY_INSTALL/post-install/pacman.sh" -run_logged "$OMARCHY_INSTALL/post-install/udev.sh" -run_logged "$OMARCHY_INSTALL/post-install/localdb.sh" +source "$OMARCHY_INSTALL/login/all.sh" +source "$OMARCHY_INSTALL/post-install/all.sh" stop_install_log diff --git a/bin/omarchy-shell b/bin/omarchy-shell index 93312327..5bd615a8 100755 --- a/bin/omarchy-shell +++ b/bin/omarchy-shell @@ -40,6 +40,13 @@ fi [[ -n ${OMARCHY_PATH:-} ]] || fail "OMARCHY_PATH is not set" [[ -f $OMARCHY_PATH/shell/shell.qml ]] || fail "omarchy-shell config not found: $OMARCHY_PATH/shell/shell.qml" +# qs matches instances by display, and tmux run-shell strips WAYLAND_DISPLAY +# from hooks, so recover it from the compositor socket when it is missing. +if [[ -z ${WAYLAND_DISPLAY:-} ]]; then + socket=$(ls -t "${XDG_RUNTIME_DIR:-/run/user/$UID}"/wayland-[0-9]* 2>/dev/null | grep -v '\.lock$' | head -n1) + [[ -n $socket ]] && export WAYLAND_DISPLAY=${socket##*/} +fi + if [[ $1 == "shell" && ( $2 == "summon" || $2 == "toggle" ) ]] && (( $# == 3 )); then set -- "$1" "$2" "$3" "{}" fi diff --git a/bin/omarchy-shell-config b/bin/omarchy-shell-config new file mode 100755 index 00000000..5cf0f639 --- /dev/null +++ b/bin/omarchy-shell-config @@ -0,0 +1,62 @@ +#!/bin/bash + +# omarchy:summary=Shared helpers for editing ~/.config/omarchy/shell.json (source this, don't run it). +# omarchy:hidden=true + +CONFIG_FILE="$HOME/.config/omarchy/shell.json" +DEFAULTS_FILE="$OMARCHY_PATH/config/omarchy/shell.json" + +fail() { + echo "${0##*/}: $*" >&2 + exit 1 +} + +refresh_shell_config() { + if ! omarchy-shell shell reloadConfig >/dev/null 2>&1; then + omarchy-shell -q shell rescanPlugins >/dev/null 2>&1 || true + fi +} + +source_file() { + if [[ -s $CONFIG_FILE ]]; then + printf '%s\n' "$CONFIG_FILE" + else + printf '%s\n' "$DEFAULTS_FILE" + fi +} + +# jq pipeline that normalizes shell.json into a well-shaped object with +# version=1, bar.layout.{left,center,right} arrays, and plugins array. Every +# mutation pipes through this so downstream jq can assume structure. +NORMALIZE=' + def object_or_empty: if type == "object" then . else {} end; + def array_or_empty: if type == "array" then . else [] end; + object_or_empty + | .version = 1 + | .bar = (.bar | object_or_empty) + | .bar.layout = (.bar.layout | object_or_empty) + | .bar.layout.left = (.bar.layout.left | array_or_empty) + | .bar.layout.center = (.bar.layout.center | array_or_empty) + | .bar.layout.right = (.bar.layout.right | array_or_empty) + | .plugins = (.plugins | array_or_empty) +' + +# Apply a jq program to the source file and atomically write the result to the +# user config, then refresh the running shell. Extra args after the program are +# forwarded to jq (e.g. --arg/--argjson). +_SHELL_CONFIG_TMP="" +cleanup_shell_config_tmp() { + if [[ -n $_SHELL_CONFIG_TMP ]]; then rm -f "$_SHELL_CONFIG_TMP"; fi +} +trap cleanup_shell_config_tmp EXIT + +commit() { + local program="$1" + shift + mkdir -p "$(dirname "$CONFIG_FILE")" + _SHELL_CONFIG_TMP=$(mktemp) + jq -S -e "$@" "$program" "$(source_file)" >"$_SHELL_CONFIG_TMP" || fail "could not update shell config" + mv "$_SHELL_CONFIG_TMP" "$CONFIG_FILE" + _SHELL_CONFIG_TMP="" + refresh_shell_config +} diff --git a/bin/omarchy-system-lid-close b/bin/omarchy-system-lid-close new file mode 100755 index 00000000..eb3dc097 --- /dev/null +++ b/bin/omarchy-system-lid-close @@ -0,0 +1,20 @@ +#!/bin/bash + +# omarchy:summary=Lock and reconcile displays when the laptop lid closes +# omarchy:group=system +# omarchy:hidden=true + +# Locking here rather than waiting for PrepareForSleep is what keeps the lock +# off the critical path. logind's delay inhibitor is a timer that expires +# whether or not the session is secure, so starting the lock the moment the lid +# closes gives Quickshell a head start before logind even decides to suspend. +# omarchy-system-sleep-lock then usually finds the session already secure. +# +# A docked lid close does not suspend (HandleLidSwitchDocked defaults to +# ignore), so it must not lock either: that is clamshell mode, still in use on +# the external display. +if omarchy-hw-laptop-closed && ! omarchy-hw-external-monitors; then + omarchy-system-lock >/dev/null 2>&1 || true +fi + +omarchy-hyprland-monitor-clamshell diff --git a/bin/omarchy-system-logout b/bin/omarchy-system-logout index 4451db9f..cb0fd660 100755 --- a/bin/omarchy-system-logout +++ b/bin/omarchy-system-logout @@ -6,7 +6,7 @@ nohup bash -c "sleep 2 && uwsm stop" >/dev/null 2>&1 & -omarchy-osd -i logout -m "Logging out…" -d 5000 --fit +omarchy-osd -i logout -m "Logging out" -d 5000 # Now close all windows omarchy-hyprland-window-close-all diff --git a/bin/omarchy-system-reboot b/bin/omarchy-system-reboot index c01c3cb2..4d2b6c86 100755 --- a/bin/omarchy-system-reboot +++ b/bin/omarchy-system-reboot @@ -8,7 +8,7 @@ # scope cannot terminate it before it runs. systemd-run --user --collect --quiet --on-active="2s" --timer-property=AccuracySec=100ms systemctl reboot --no-wall || exit 1 -omarchy-osd -i reboot -m "Rebooting…" -d 5000 --fit +omarchy-osd -i reboot -m "Rebooting" -d 5000 omarchy-state clear re*-required diff --git a/bin/omarchy-system-shutdown b/bin/omarchy-system-shutdown index e4021e60..0143760e 100755 --- a/bin/omarchy-system-shutdown +++ b/bin/omarchy-system-shutdown @@ -8,7 +8,7 @@ # scope cannot terminate it before it runs. systemd-run --user --collect --quiet --on-active="2s" --timer-property=AccuracySec=100ms systemctl poweroff --no-wall || exit 1 -omarchy-osd -i shutdown -m "Shutting down…" -d 5000 --fit +omarchy-osd -i shutdown -m "Shutting down" -d 5000 omarchy-state clear re*-required diff --git a/bin/omarchy-system-sleep-lock b/bin/omarchy-system-sleep-lock index 3d9e5df5..d471cea0 100755 --- a/bin/omarchy-system-sleep-lock +++ b/bin/omarchy-system-sleep-lock @@ -4,29 +4,121 @@ # omarchy:group=system # omarchy:hidden=true -wait_attempts=${1:-100} +# Overrunning the budget is the failure this whole path exists to prevent: +# logind stops honouring the inhibitor and suspends mid-lock. Every call below +# is bounded by what is left of the budget, so the deadline enforces itself +# rather than depending on an estimate of how long a step ought to take. +budget_cap_ms=12000 +lock_timeout_ms=1000 +status_timeout_ms=500 +poll_interval=0.1 -if [[ ! $wait_attempts =~ ^[0-9]+$ ]] || (( wait_attempts < 1 )); then - wait_attempts=100 -fi +# logind decides how long a delay inhibitor may hold the machine, and the +# shipped drop-in only counts once logind has reloaded it, so ask rather than +# assume. Leaving logind a fifth of its own window to deliver PrepareForSleep +# and act on the release gives 4s at the 5s default and 12s at the shipped 15s. +# The cap keeps a hand-raised window from stranding a closed laptop in a bag. +derive_budget_ms() { + local window + window=$(timeout --kill-after=0.1s 1s busctl get-property \ + org.freedesktop.login1 /org/freedesktop/login1 \ + org.freedesktop.login1.Manager InhibitDelayMaxUSec 2>/dev/null) + window=${window##* } -sync_clamshell() { - omarchy-hyprland-monitor-clamshell >/dev/null 2>&1 || true + # An unreadable window means we cannot know, so assume logind's own default. + [[ $window =~ ^[0-9]+$ ]] && (( window > 0 )) || window=5000000 + window=$((window / 1000)) + + # Never leave logind less than a second, however small its window is. + window=$((window - (window / 5 > 1000 ? window / 5 : 1000))) + + (( window < budget_cap_ms )) && echo "$window" || echo "$budget_cap_ms" } +budget_ms=${1:-$(derive_budget_ms)} +if [[ ! $budget_ms =~ ^[0-9]+$ ]] || (( budget_ms < 1 || budget_ms > budget_cap_ms )); then + budget_ms=$(derive_budget_ms) +fi + +# EPOCHREALTIME renders with the locale's decimal separator, so drop every +# non-digit rather than assuming a period. A comma would otherwise read as +# bash's comma operator and silently void the deadline. +deadline_ms=$((10#${EPOCHREALTIME//[!0-9]/} / 1000 + budget_ms)) + +remaining_ms() { + echo $((deadline_ms - 10#${EPOCHREALTIME//[!0-9]/} / 1000)) +} + +# Clamping to what is left as well as to the call's own limit is what lets the +# loop below stay a plain "while there is time" without predicting step costs. +lock_ipc() { + local limit=$1 remaining seconds + shift + + remaining=$(remaining_ms) + (( remaining > 0 )) || return 1 + (( limit < remaining )) || limit=$remaining + printf -v seconds '%d.%03d' $((limit / 1000)) $((limit % 1000)) + + OMARCHY_SHELL_IPC_TIMEOUT="$seconds" \ + timeout --kill-after=0.1s "$seconds" omarchy-shell "$@" +} + +# The shell answers refusals on stdout with a zero exit, so the reply is the +# only way to spot a lock it can never perform. Nothing else needs inspecting: +# the status poll is what confirms success, and re-requesting is idempotent, so +# a request that may not have landed costs nothing to repeat. +request_lock() { + case $(lock_ipc "$lock_timeout_ms" lock lock 2>/dev/null) in + missing-pam) report_unsecured "no lock screen is configured" ;; + esac +} + +# secure: done. locking: the shell has the request and is working on it, so +# leave it alone. Anything else, unreadable replies included, means ask again. +lock_state() { + jq -r 'if .secure == true then "secure" + elif .requested == true then "locking" + else "idle" end' \ + <<<"$(lock_ipc "$status_timeout_ms" lock status 2>/dev/null)" 2>/dev/null +} + +sync_clamshell() { + # Lid transitions can temporarily stall Hyprland IPC. This is best-effort: + # the lid binding and monitor watcher also reconcile clamshell state. + timeout --kill-after=0.1s 0.4s \ + omarchy-hyprland-monitor-clamshell >/dev/null 2>&1 || true +} + +# logind suspends whether or not this wait succeeded, so a failure here means +# the machine slept with the session exposed. The notification is the only way +# anyone finds out, and it lands on the screen they unlock into. +report_unsecured() { + printf 'omarchy-system-sleep-lock: suspending without a secure lock (%s)\n' \ + "$1" >&2 + + omarchy-notification-send -u critical -g 󰌾 \ + "Screen did not lock before suspend" \ + "The session was left unlocked ($1)." >/dev/null 2>&1 || true + + exit 1 +} + +# Request the lock before touching monitor state, so a stuck Hyprland IPC call +# cannot consume the window before Quickshell has begun securing the session. +request_lock sync_clamshell -omarchy-shell lock lock >/dev/null 2>&1 || exit 1 -for (( attempt = 0; attempt < wait_attempts; attempt++ )); do - (( attempt % 5 == 0 )) && sync_clamshell +# The trailing sleep can overshoot the deadline by one interval, which is well +# inside the reserve derive_budget_ms already held back for logind. +while (( $(remaining_ms) > 0 )); do + case $(lock_state) in + secure) exit 0 ;; + locking) ;; + *) request_lock ;; + esac - status=$(omarchy-shell lock status 2>/dev/null || true) - - if jq -e '.secure == true' <<<"$status" >/dev/null 2>&1; then - exit 0 - fi - - sleep 0.05 + sleep "$poll_interval" done -exit 1 +report_unsecured "the shell did not secure the session within ${budget_ms}ms" diff --git a/bin/omarchy-theme-color b/bin/omarchy-theme-color index b4491f06..cbbab0e2 100755 --- a/bin/omarchy-theme-color +++ b/bin/omarchy-theme-color @@ -26,7 +26,7 @@ usage() { echo "Usage: omarchy-theme-color [--file ] (--all | --raw | [fallback])" } -while [[ $# -gt 0 ]]; do +while (( $# > 0 )); do case "$1" in --file) COLORS_FILE="${2:-}" diff --git a/bin/omarchy-theme-current b/bin/omarchy-theme-current index edc6a462..1a9072cb 100755 --- a/bin/omarchy-theme-current +++ b/bin/omarchy-theme-current @@ -6,7 +6,7 @@ THEME_NAME_PATH="$HOME/.local/state/omarchy/current/theme.name" if [[ -f $THEME_NAME_PATH ]]; then - cat $THEME_NAME_PATH | sed -E 's/(^|-)([a-z])/\1\u\2/g; s/-/ /g' + sed -E 's/(^|-)([a-z])/\1\u\2/g; s/-/ /g' "$THEME_NAME_PATH" else echo "Unknown" fi diff --git a/bin/omarchy-theme-dir b/bin/omarchy-theme-dir new file mode 100755 index 00000000..83843c5a --- /dev/null +++ b/bin/omarchy-theme-dir @@ -0,0 +1,18 @@ +#!/bin/bash + +# omarchy:summary=Print the directory holding a theme, preferring a user-installed copy +# omarchy:args= +# omarchy:examples=omarchy theme dir tokyo-night + +theme="${1:-}" + +if [[ -z $theme ]]; then + echo "Usage: omarchy-theme-dir " >&2 + exit 1 +fi + +if [[ -d $HOME/.config/omarchy/themes/$theme ]]; then + echo "$HOME/.config/omarchy/themes/$theme" +else + echo "$OMARCHY_PATH/themes/$theme" +fi diff --git a/bin/omarchy-theme-list b/bin/omarchy-theme-list index 0b901fc0..318347b5 100755 --- a/bin/omarchy-theme-list +++ b/bin/omarchy-theme-list @@ -6,6 +6,4 @@ { find ~/.config/omarchy/themes/ -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -printf '%f\n' find "$OMARCHY_PATH/themes/" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' -} | sort -u | while read -r name; do - echo "$name" | sed -E 's/(^|-)([a-z])/\1\u\2/g; s/-/ /g' -done +} | sort -u | sed -E 's/(^|-)([a-z])/\1\u\2/g; s/-/ /g' diff --git a/bin/omarchy-tmux-alert b/bin/omarchy-tmux-alert new file mode 100755 index 00000000..0dfbf990 --- /dev/null +++ b/bin/omarchy-tmux-alert @@ -0,0 +1,140 @@ +#!/bin/bash + +# omarchy:summary=Show or jump to tmux windows waiting for attention +# omarchy:args= +# omarchy:examples=omarchy tmux alert show | omarchy tmux alert focus + +set -e + +# Session names cannot contain colons, and window names can, so they always +# come last when parsed. +WINDOW_FORMAT='#{window_id}:#{window_bell_flag}#{window_activity_flag}#{window_silence_flag}:#{window_activity}:#{@omarchy_unfocused_activity}:#{session_name}:#{window_index}:#{window_name}' +CLIENT_FORMAT='#{client_flags}:#{window_id}' + +usage() { + echo "Usage: omarchy-tmux-alert " >&2 + exit 1 +} + +# Include tmux alert flags plus output newer than the last focus transition for +# selected windows whose attached terminals are all unfocused. +alerted_windows() { + local client_flags window_id flags activity seen_activity session index name + local waiting + local -A attached_windows=() + local -A focused_windows=() + + while IFS=':' read -r client_flags window_id; do + [[ -n $window_id ]] || continue + attached_windows[$window_id]=1 + if [[ ,$client_flags, == *",focused,"* ]]; then + focused_windows[$window_id]=1 + fi + done < <(tmux list-clients -F "$CLIENT_FORMAT" 2>/dev/null) + + while IFS=':' read -r window_id flags activity seen_activity session index name; do + waiting=0 + [[ $flags == *1* ]] && waiting=1 + + if [[ ${attached_windows[$window_id]:-} == "1" && + ${focused_windows[$window_id]:-} != "1" && + $activity =~ ^[0-9]+$ && + $seen_activity =~ ^[0-9]+$ ]] && ((activity > seen_activity)); then + waiting=1 + fi + + ((waiting)) && printf '%s:%s:%s\n' "$session" "$index" "$name" + done < <(tmux list-windows -a -F "$WINDOW_FORMAT" 2>/dev/null) +} + +show() { + local windows=() window session index name description="" + + readarray -t windows < <(alerted_windows) + + for window in "${windows[@]}"; do + IFS=':' read -r session index name <<<"$window" + [[ -n $description ]] && description+=", " + description+="$name ($session:$index)" + done + + if [[ ${1:-} == "--json" ]]; then + jq -nc --argjson count "${#windows[@]}" --arg tooltip "$description" '{count: $count, tooltip: $tooltip}' + elif [[ -n $description ]]; then + echo "$description" + fi +} + +focus_terminal_window() { + local pid=$1 address="" window_pid window_address + local -A windows=() + + while read -r window_pid window_address; do + windows[$window_pid]=$window_address + done < <(hyprctl clients -j | jq -r '.[] | "\(.pid) \(.address)"') + + # The tmux client runs somewhere below the terminal that owns the window. + while [[ -n $pid ]] && (( pid > 1 )); do + if [[ -n ${windows[$pid]:-} ]]; then + address=${windows[$pid]} + break + fi + pid=$(awk '/^PPid:/ { print $2 }' "/proc/$pid/status" 2>/dev/null) + done + + [[ -n $address ]] || return 1 + + hyprctl dispatch "hl.dsp.focus({ window = \"address:$address\" })" >/dev/null 2>&1 || + hyprctl dispatch focuswindow "address:$address" >/dev/null +} + +most_recent_client() { + tmux list-clients "$@" -F '#{client_activity}:#{client_tty}:#{client_pid}' 2>/dev/null | sort -rn | head -n1 +} + +track() { + local window=${1:-} activity=${2:-} + + [[ $window =~ ^@[0-9]+$ && $activity =~ ^[0-9]+$ ]] || return 0 + tmux set-option -wq -t "$window" @omarchy_unfocused_activity "$activity" 2>/dev/null || return 0 + omarchy-shell -q omarchy.indicators refresh +} + +focus() { + local target session index client tty pid + + target=$(alerted_windows | head -n1) + [[ -n $target ]] || return 0 + + IFS=':' read -r session index _ <<<"$target" + + client=$(most_recent_client -t "$session") + [[ -n $client ]] || client=$(most_recent_client) + + if [[ -z $client ]]; then + tmux select-window -t "$session:$index" + exec omarchy-launch-terminal tmux attach -t "$session" + fi + + IFS=':' read -r _ tty pid <<<"$client" + tmux switch-client -c "$tty" -t "$session:$index" + omarchy-shell -q omarchy.indicators refresh + focus_terminal_window "$pid" +} + +case "${1:-}" in + show) + shift + show "$@" + ;; + focus) + focus + ;; + track) + shift + track "$@" + ;; + *) + usage + ;; +esac diff --git a/bin/omarchy-toggle-input-device b/bin/omarchy-toggle-input-device new file mode 100755 index 00000000..aea0ca11 --- /dev/null +++ b/bin/omarchy-toggle-input-device @@ -0,0 +1,50 @@ +#!/bin/bash + +# omarchy:summary=Enable, disable, or toggle a Hyprland input device +# omarchy:args= [on|off|toggle] +# omarchy:hidden=true + +KIND="${1:-}" +ACTION="${2:-toggle}" + +case "$KIND" in + touchpad) LABEL="Touchpad" ICON="touchpad" ;; + touchscreen) LABEL="Touchscreen" ICON="touch" ;; + *) + echo "Usage: omarchy-toggle-input-device [on|off|toggle]" >&2 + exit 1 + ;; +esac + +# Hyprland sources this directory on reload, so the disabled state survives restarts +STATE_FILE="$HOME/.local/state/omarchy/toggles/hypr/$KIND-disabled.lua" + +device="$("omarchy-hw-$KIND")" + +if [[ -z $device ]]; then + echo "No $KIND device found" >&2 + exit 1 +fi + +enable() { + hyprctl eval "hl.device({ name = \"$device\", enabled = true })" >/dev/null + rm -f "$STATE_FILE" + omarchy-osd -i "$ICON" -m "$LABEL enabled" +} + +disable() { + hyprctl eval "hl.device({ name = \"$device\", enabled = false })" >/dev/null + mkdir -p "$(dirname "$STATE_FILE")" + printf 'hl.device({ name = "%s", enabled = false })\n' "$device" >"$STATE_FILE" + omarchy-osd -i "$ICON" -m "$LABEL disabled" +} + +case "$ACTION" in + on) enable ;; + off) disable ;; + toggle) if [[ -f $STATE_FILE ]]; then enable; else disable; fi ;; + *) + echo "Usage: omarchy-toggle-input-device [on|off|toggle]" >&2 + exit 1 + ;; +esac diff --git a/bin/omarchy-toggle-screensaver b/bin/omarchy-toggle-screensaver index a0f74ea7..8f631318 100755 --- a/bin/omarchy-toggle-screensaver +++ b/bin/omarchy-toggle-screensaver @@ -2,10 +2,10 @@ # omarchy:summary=Toggle screensaver availability +omarchy-toggle screensaver-off + if omarchy-toggle-enabled screensaver-off; then - omarchy-toggle screensaver-off - omarchy-notification-send -g 󱄄 "Screensaver enabled" -else - omarchy-toggle screensaver-off omarchy-notification-send -g 󱄄 "Screensaver disabled" +else + omarchy-notification-send -g 󱄄 "Screensaver enabled" fi diff --git a/bin/omarchy-toggle-suspend b/bin/omarchy-toggle-suspend index 22cda36f..736e23d4 100755 --- a/bin/omarchy-toggle-suspend +++ b/bin/omarchy-toggle-suspend @@ -2,10 +2,10 @@ # omarchy:summary=Toggle suspend availability in the system menu +omarchy-toggle suspend-off + if omarchy-toggle-enabled suspend-off; then - omarchy-toggle suspend-off - omarchy-notification-send -g 󰒲 "Suspend now available in system menu" -else - omarchy-toggle suspend-off omarchy-notification-send -g 󰒲 "Suspend removed from system menu" +else + omarchy-notification-send -g 󰒲 "Suspend now available in system menu" fi diff --git a/bin/omarchy-toggle-touchpad b/bin/omarchy-toggle-touchpad index 3ab72616..f218efb8 100755 --- a/bin/omarchy-toggle-touchpad +++ b/bin/omarchy-toggle-touchpad @@ -3,30 +3,4 @@ # omarchy:summary=Enable, disable, or toggle the touchpad # omarchy:args=[on|off|toggle] -STATE_FILE="$HOME/.local/state/omarchy/toggles/hypr/touchpad-disabled.lua" - -device="$(omarchy-hw-touchpad)" - -if [[ -z $device ]]; then - echo "No touchpad device found" >&2 - exit 1 -fi - -enable() { - hyprctl eval "hl.device({ name = \"$device\", enabled = true })" >/dev/null - rm -f "$STATE_FILE" - omarchy-osd -i touchpad -m "Touchpad enabled" -} - -disable() { - hyprctl eval "hl.device({ name = \"$device\", enabled = false })" >/dev/null - mkdir -p "$(dirname "$STATE_FILE")" - printf 'hl.device({ name = "%s", enabled = false })\n' "$device" >"$STATE_FILE" - omarchy-osd -i touchpad -m "Touchpad disabled" -} - -case "${1:-toggle}" in - on) enable ;; - off) disable ;; - toggle) if [[ -f $STATE_FILE ]]; then enable; else disable; fi ;; -esac +exec omarchy-toggle-input-device touchpad "${1:-toggle}" diff --git a/bin/omarchy-toggle-touchscreen b/bin/omarchy-toggle-touchscreen index 45b1284f..320c9a3d 100755 --- a/bin/omarchy-toggle-touchscreen +++ b/bin/omarchy-toggle-touchscreen @@ -3,30 +3,4 @@ # omarchy:summary=Enable, disable, or toggle the touch functionality of the screen # omarchy:args=[on|off|toggle] -STATE_FILE="$HOME/.local/state/omarchy/toggles/hypr/touchscreen-disabled.lua" - -device="$(omarchy-hw-touchscreen)" - -if [[ -z $device ]]; then - echo "No touchscreen device found" >&2 - exit 1 -fi - -enable() { - hyprctl eval "hl.device({ name = \"$device\", enabled = true })" >/dev/null - rm -f "$STATE_FILE" - omarchy-osd -i touch -m "Touchscreen enabled" -} - -disable() { - hyprctl eval "hl.device({ name = \"$device\", enabled = false })" >/dev/null - mkdir -p "$(dirname "$STATE_FILE")" - printf 'hl.device({ name = "%s", enabled = false })\n' "$device" >"$STATE_FILE" - omarchy-osd -i touch -m "Touchscreen disabled" -} - -case "${1:-toggle}" in - on) enable ;; - off) disable ;; - toggle) if [[ -f $STATE_FILE ]]; then enable; else disable; fi ;; -esac +exec omarchy-toggle-input-device touchscreen "${1:-toggle}" diff --git a/bin/omarchy-update b/bin/omarchy-update index 22d9edbb..99a6026f 100755 --- a/bin/omarchy-update +++ b/bin/omarchy-update @@ -73,6 +73,7 @@ run_update_pipeline() { disable_sleep_for_update disable_idle_for_update + omarchy-update-dev omarchy-update-keyring omarchy-update-system-pkgs omarchy-migrate @@ -90,6 +91,12 @@ run_update_pipeline() { omarchy-shell -q omarchy.system-update clear fi + # Release update-owned inhibitors before offering a reboot. A confirmed + # reboot can terminate this process before its EXIT trap gets a chance to + # remove the persistent Stay Awake marker. + restore_update_inhibitors + trap - EXIT + omarchy-update-restart } diff --git a/bin/omarchy-update-available b/bin/omarchy-update-available index 2c824f5f..f1a70b25 100755 --- a/bin/omarchy-update-available +++ b/bin/omarchy-update-available @@ -1,9 +1,25 @@ #!/bin/bash -# omarchy:summary=Check whether Omarchy package updates are available. +# omarchy:summary=Check whether Omarchy updates are available. set -euo pipefail +updates=() + +if [[ $OMARCHY_PATH != "/usr/share/omarchy" ]]; then + upstream=$(git -C "$OMARCHY_PATH" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || true) + if [[ -n $upstream ]]; then + GIT_TERMINAL_PROMPT=0 timeout 10 git -C "$OMARCHY_PATH" fetch --quiet 2>/dev/null || true + + behind=$(git -C "$OMARCHY_PATH" rev-list --count "HEAD..$upstream" 2>/dev/null || echo 0) + if (( behind > 0 )); then + commit_label=commits + (( behind == 1 )) && commit_label=commit + updates+=("omarchy-dev-checkout $behind new $commit_label on $upstream") + fi + fi +fi + package="" if pacman -Qq omarchy-dev >/dev/null 2>&1; then package=omarchy-dev @@ -16,7 +32,11 @@ if [[ -n $package ]]; then fi if [[ -n ${update:-} ]]; then - printf '%s\n' "$update" + updates+=("$update") +fi + +if (( ${#updates[@]} > 0 )); then + printf '%s\n' "${updates[@]}" exit 0 else echo "Omarchy is up to date" diff --git a/bin/omarchy-update-dev b/bin/omarchy-update-dev new file mode 100755 index 00000000..a973246e --- /dev/null +++ b/bin/omarchy-update-dev @@ -0,0 +1,21 @@ +#!/bin/bash + +# omarchy:summary=Update the active Omarchy dev checkout + +set -euo pipefail + +[[ $OMARCHY_PATH != "/usr/share/omarchy" ]] || exit 0 + +if ! git -C "$OMARCHY_PATH" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Error: OMARCHY_PATH is not a git checkout: $OMARCHY_PATH" >&2 + exit 1 +fi + +upstream=$(git -C "$OMARCHY_PATH" rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' 2>/dev/null || true) +if [[ -z $upstream ]]; then + echo -e "\e[33m\nSkip Omarchy dev checkout update (current branch has no upstream)\e[0m" + exit 0 +fi + +echo -e "\e[32m\nUpdate Omarchy dev checkout\e[0m" +git -C "$OMARCHY_PATH" pull --ff-only diff --git a/bin/omarchy-update-system-pkgs b/bin/omarchy-update-system-pkgs index ac12a999..7b197504 100755 --- a/bin/omarchy-update-system-pkgs +++ b/bin/omarchy-update-system-pkgs @@ -11,6 +11,10 @@ echo -e "\e[32m\nUpdate system packages\e[0m" # as unowned files; pacman would refuse the omarchy-settings upgrade on first # encounter. Drop each entry once the transition release is the baseline. # +# An entry has to ship at least one release before the package starts owning the +# path. pacman checks file conflicts during transaction prepare, so the copy of +# this script running the upgrade is the one already on disk. +# # The /usr/share/omarchy/* entry is permanent: that tree is wholly owned by # the omarchy packages, but files can land there unowned (in-place extension # work, script-written files), which would abort the whole upgrade. Packaged diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 03c7394c..b431ef34 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1492,7 +1492,6 @@ always_copy_config_files=( hypr/input.lua hypr/looknfeel.lua hypr/monitors.lua - omarchy/bar.json omarchy/extensions/omarchy-menu.jsonc omarchy/hooks/pre-refresh-pacman.d/add-custom-repo.sample omarchy/shell.json @@ -2128,7 +2127,7 @@ if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database "$HOME/.local/share/applications" >/dev/null 2>&1 || true fi if command -v xdg-settings >/dev/null 2>&1; then - xdg-settings set default-web-browser chromium.desktop || true + env -u BROWSER xdg-settings set default-web-browser chromium.desktop || true fi if command -v xdg-mime >/dev/null 2>&1; then xdg-mime default HEY.desktop x-scheme-handler/mailto || true diff --git a/bin/omarchy-wifi-powersave b/bin/omarchy-wifi-powersave deleted file mode 100755 index d75434a1..00000000 --- a/bin/omarchy-wifi-powersave +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash - -# omarchy:summary=Set Wi-Fi power save mode on wireless interfaces -# omarchy:args= - -shopt -s nullglob -for iface in /sys/class/net/*/wireless; do - iface="$(basename "$(dirname "$iface")")" - iw dev "$iface" set power_save "$1" 2>/dev/null -done diff --git a/config/hypr/bindings.lua b/config/hypr/bindings.lua index d8eb55a9..98d3ecbe 100644 --- a/config/hypr/bindings.lua +++ b/config/hypr/bindings.lua @@ -26,4 +26,4 @@ -- Logitech MX Keys examples: -- o.bind("SUPER + SHIFT + S", nil, "omarchy-capture-screenshot") -- o.bind("SUPER + H", nil, "voxtype record toggle") --- o.bind("SUPER + PERIOD", nil, { omarchy = "walker -m symbols" }) +-- o.bind("SUPER + PERIOD", nil, "omarchy-shell shell toggle omarchy.emojis") diff --git a/config/omarchy/bar.json b/config/omarchy/bar.json deleted file mode 100644 index 0967ef42..00000000 --- a/config/omarchy/bar.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/config/omarchy/shell.json b/config/omarchy/shell.json index a1d73f13..a3f4c329 100644 --- a/config/omarchy/shell.json +++ b/config/omarchy/shell.json @@ -18,6 +18,9 @@ } ], "center": [ + { + "id": "omarchy.indicators" + }, { "id": "omarchy.clock", "format": "dddd HH:mm", @@ -29,9 +32,6 @@ }, { "id": "omarchy.system-update" - }, - { - "id": "omarchy.indicators" } ], "right": [ diff --git a/config/tmux/tmux.conf b/config/tmux/tmux.conf index d73c42ea..b9800d01 100644 --- a/config/tmux/tmux.conf +++ b/config/tmux/tmux.conf @@ -81,6 +81,15 @@ set -ag terminal-features "xterm-kitty:extkeys" set -as terminal-features ",*:clipboard" set -sg escape-time 10 +# Alerts +set-hook -g alert-bell 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' +set-hook -g alert-activity 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' +set-hook -g alert-silence 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' +set-hook -g after-select-window 'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"' +set-hook -g client-session-changed 'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"' +set-hook -g client-focus-out[100] 'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"' +set-hook -g client-focus-in[100] 'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"' + # Status bar set -g status-position top set -g status-interval 5 diff --git a/default/audio/filter-chain-host.conf b/default/audio/filter-chain-host.conf new file mode 100644 index 00000000..4951baea --- /dev/null +++ b/default/audio/filter-chain-host.conf @@ -0,0 +1,40 @@ +# Host config for the Omarchy speaker tuning. +# +# This exists so the tuning gets its own PipeWire client rather than sharing +# PipeWire's stock filter-chain.conf. That config merges every fragment in +# ~/.config/pipewire/filter-chain.conf.d/, so hosting the tuning there would load +# any unrelated filter a user keeps in that directory -- duplicating filters +# already hosted elsewhere, and stopping them all when the tuning is switched off. +# +# Installed as ~/.config/pipewire/omarchy-speaker-tuning.conf with the tuning +# graph merged from omarchy-speaker-tuning.conf.d/, and run with +# pipewire -c omarchy-speaker-tuning.conf +# +# The contents are the minimum a filter-hosting client needs, taken from +# /usr/share/pipewire/filter-chain.conf. + +context.properties = { + log.level = 0 +} + +context.spa-libs = { + audio.convert.* = audioconvert/libspa-audioconvert + support.* = support/libspa-support +} + +context.modules = [ + # Boost the audio thread priority. + { name = libpipewire-module-rt + args = { } + flags = [ ifexists nofail ] + } + + # The native communication protocol. + { name = libpipewire-module-protocol-native } + + # Lets this process provide nodes to PipeWire. + { name = libpipewire-module-client-node } + + # Wraps nodes in an adapter with a converter and resampler. + { name = libpipewire-module-adapter } +] diff --git a/default/audio/tunings/dell-xps-2026/filter-chain.conf b/default/audio/tunings/dell-xps-2026/filter-chain.conf new file mode 100644 index 00000000..04810a8f --- /dev/null +++ b/default/audio/tunings/dell-xps-2026/filter-chain.conf @@ -0,0 +1,140 @@ +# Dell XPS 14 / XPS 16 (2026) speaker tuning. +# +# Biquad chain fitted to the measured response of the xps-audio-linux EasyEffects +# profile under a dense pink-weighted multitone of 104 bin-aligned tones, +# followed by a lookahead limiter. Measures 1.24 dB RMS against that reference +# (0.97 dB weighted over the fit's own error metric). +# +# Fitted and measured on the XPS 14 (SKU 0DB9); the XPS 16 (0DBA) is covered on +# report that the same profile suits it. See tuning.conf. +# +# Q below 200 Hz is capped at 1.8 on purpose. A closer magnitude fit is possible +# with high-Q sections, but the reference produces its narrow bass features by +# convolution, and reproducing them with high-Q biquads swung group delay 31 ms +# across 63-80 Hz, which smears bass transients. The cap costs 0.33 dB and +# halves the swing. +# +# This is a plain filter-chain sink rather than a WirePlumber smart filter. A +# smart filter is the better shape -- it would leave the real device as the +# default output instead of adding a second one -- but on PipeWire 1.6.8 / +# WirePlumber 0.5.15 this graph loads and links correctly as a smart filter and +# then passes audio through unprocessed: its controls are present and +# mpv -> filter -> sink links are made, yet the filter's input monitor and the +# speaker sink's monitor measure identically. Revisit when that is understood. +# +# Channels are wired explicitly because the limiter is a stereo plugin; a mono +# graph is duplicated per channel and would limit each side independently, +# shifting the stereo image on bass transients. + +context.modules = [ + { name = libpipewire-module-filter-chain + args = { + node.description = "Laptop Speakers" + media.name = "Laptop Speakers" + + filter.graph = { + nodes = [ + { type = builtin name = s0_l label = bq_highpass control = { "Freq" = 60.9 "Q" = 1.0 } } + { type = builtin name = s1_l label = bq_highpass control = { "Freq" = 60.9 "Q" = 1.0 } } + { type = builtin name = s2_l label = bq_peaking control = { "Freq" = 83.4 "Q" = 1.8 "Gain" = -8.0 } } + { type = builtin name = s3_l label = bq_peaking control = { "Freq" = 100.4 "Q" = 1.59 "Gain" = 7.47 } } + { type = builtin name = s4_l label = bq_peaking control = { "Freq" = 250.5 "Q" = 2.966 "Gain" = -4.7 } } + { type = builtin name = s5_l label = bq_peaking control = { "Freq" = 419.8 "Q" = 3.0 "Gain" = -5.83 } } + { type = builtin name = s6_l label = bq_peaking control = { "Freq" = 631.3 "Q" = 2.515 "Gain" = -10.33 } } + { type = builtin name = s7_l label = bq_peaking control = { "Freq" = 894.4 "Q" = 4.0 "Gain" = -2.42 } } + { type = builtin name = s8_l label = bq_peaking control = { "Freq" = 1355.7 "Q" = 2.884 "Gain" = 6.92 } } + { type = builtin name = s9_l label = bq_peaking control = { "Freq" = 1707.2 "Q" = 1.311 "Gain" = -6.54 } } + { type = builtin name = s10_l label = bq_peaking control = { "Freq" = 3100.0 "Q" = 0.5 "Gain" = -10.09 } } + { type = builtin name = s11_l label = bq_peaking control = { "Freq" = 3200.0 "Q" = 1.048 "Gain" = 3.09 } } + { type = builtin name = s12_l label = bq_highshelf control = { "Freq" = 6015.2 "Q" = 1.5 "Gain" = -1.34 } } + + { type = builtin name = s0_r label = bq_highpass control = { "Freq" = 60.9 "Q" = 1.0 } } + { type = builtin name = s1_r label = bq_highpass control = { "Freq" = 60.9 "Q" = 1.0 } } + { type = builtin name = s2_r label = bq_peaking control = { "Freq" = 83.4 "Q" = 1.8 "Gain" = -8.0 } } + { type = builtin name = s3_r label = bq_peaking control = { "Freq" = 100.4 "Q" = 1.59 "Gain" = 7.47 } } + { type = builtin name = s4_r label = bq_peaking control = { "Freq" = 250.5 "Q" = 2.966 "Gain" = -4.7 } } + { type = builtin name = s5_r label = bq_peaking control = { "Freq" = 419.8 "Q" = 3.0 "Gain" = -5.83 } } + { type = builtin name = s6_r label = bq_peaking control = { "Freq" = 631.3 "Q" = 2.515 "Gain" = -10.33 } } + { type = builtin name = s7_r label = bq_peaking control = { "Freq" = 894.4 "Q" = 4.0 "Gain" = -2.42 } } + { type = builtin name = s8_r label = bq_peaking control = { "Freq" = 1355.7 "Q" = 2.884 "Gain" = 6.92 } } + { type = builtin name = s9_r label = bq_peaking control = { "Freq" = 1707.2 "Q" = 1.311 "Gain" = -6.54 } } + { type = builtin name = s10_r label = bq_peaking control = { "Freq" = 3100.0 "Q" = 0.5 "Gain" = -10.09 } } + { type = builtin name = s11_r label = bq_peaking control = { "Freq" = 3200.0 "Q" = 1.048 "Gain" = 3.09 } } + { type = builtin name = s12_r label = bq_highshelf control = { "Freq" = 6015.2 "Q" = 1.5 "Gain" = -1.34 } } + { type = lv2 + name = limiter + plugin = "http://lsp-plug.in/plugins/lv2/limiter_stereo" + control = { + # Both default to enabled: "alr" regulates level toward the + # threshold and "boost" normalises the threshold up to full + # scale. A fixed tuning must switch them off or its tone drifts + # with programme level. + "alr" = 0 + "boost" = 0 + "g_in" = 0.5456 + "th" = 0.891 + } + } + ] + + links = [ + { output = "s0_l:Out" input = "s1_l:In" } + { output = "s1_l:Out" input = "s2_l:In" } + { output = "s2_l:Out" input = "s3_l:In" } + { output = "s3_l:Out" input = "s4_l:In" } + { output = "s4_l:Out" input = "s5_l:In" } + { output = "s5_l:Out" input = "s6_l:In" } + { output = "s6_l:Out" input = "s7_l:In" } + { output = "s7_l:Out" input = "s8_l:In" } + { output = "s8_l:Out" input = "s9_l:In" } + { output = "s9_l:Out" input = "s10_l:In" } + { output = "s10_l:Out" input = "s11_l:In" } + { output = "s11_l:Out" input = "s12_l:In" } + { output = "s12_l:Out" input = "limiter:in_l" } + { output = "s0_r:Out" input = "s1_r:In" } + { output = "s1_r:Out" input = "s2_r:In" } + { output = "s2_r:Out" input = "s3_r:In" } + { output = "s3_r:Out" input = "s4_r:In" } + { output = "s4_r:Out" input = "s5_r:In" } + { output = "s5_r:Out" input = "s6_r:In" } + { output = "s6_r:Out" input = "s7_r:In" } + { output = "s7_r:Out" input = "s8_r:In" } + { output = "s8_r:Out" input = "s9_r:In" } + { output = "s9_r:Out" input = "s10_r:In" } + { output = "s10_r:Out" input = "s11_r:In" } + { output = "s11_r:Out" input = "s12_r:In" } + { output = "s12_r:Out" input = "limiter:in_r" } + ] + + inputs = [ "s0_l:In" "s0_r:In" ] + outputs = [ "limiter:out_l" "limiter:out_r" ] + } + + audio.channels = 2 + audio.position = [ FL FR ] + + capture.props = { + node.name = "omarchy_speaker_tuning" + media.class = Audio/Sink + } + playback.props = { + node.name = "omarchy_speaker_tuning_output" + node.passive = true + target.object = "@SPEAKER_SINK@" + # This stream is the filter's output and is a movable sink input like any + # other, so anything that reroutes "all streams" to a newly selected + # output would drag the processing along with it -- onto headphones, or + # into the tuning's own sink, which is a cycle. Pin it. + node.dont-move = true + # If the speaker sink is not present yet -- the tuning host can start + # before the device is discovered -- WirePlumber would otherwise link this + # output to whatever default exists, quietly tuning the wrong device while + # the tuning sink still looks healthy. Wait for the named target instead. + # Both are needed: without linger, WirePlumber destroys the node rather + # than waiting (see its scripts/linking/find-defined-target.lua). + node.dont-fallback = true + node.linger = true + } + } + } +] diff --git a/default/audio/tunings/dell-xps-2026/tuning.conf b/default/audio/tunings/dell-xps-2026/tuning.conf new file mode 100644 index 00000000..e7bb633b --- /dev/null +++ b/default/audio/tunings/dell-xps-2026/tuning.conf @@ -0,0 +1,39 @@ +## Dell XPS 14 / XPS 16 (2026) internal speakers. +## +## Thirteen biquads and a lookahead limiter, applied as a PipeWire filter-chain +## in front of the internal speaker sink. The stock Linux path already loads +## Dell's Cirrus smart-amplifier firmware; this adds the perceptual voicing the +## Windows Waves layer provides and Linux does not. + +description="Dell XPS 14/16 (2026) speakers" +## Matched on the DMI product SKU, which is what Dell keys the Cirrus speaker +## firmware on -- 10280db9 for the XPS 14 and 10280dba for the XPS 16 -- so it +## identifies the speaker hardware itself rather than a marketing name. Compared as +## whole values, so this cannot widen to the rest of the XPS line. +## +## 0DB9 XPS 14 -- measured here, see below +## 0DBA XPS 16 -- included on report that this profile suits it, not measured +match_sku=("0DB9" "0DBA") +## Unescaped dots: this is passed to awk as a string, where a backslash escape +## would be consumed before the regex sees it. +sink_pattern='^alsa_output.*sof_sdw.*HiFi__Speaker__sink$' + +## Provenance. Derived by measuring the response of the xps-clone EasyEffects +## profile from https://github.com/spencerbull/xps-audio-linux (MIT) and fitting +## a biquad chain to it. No upstream asset is redistributed: the convolution +## impulse response is not carried, so this tuning has no binary blob and is +## sample-rate agnostic. +derived_from="xps-audio-linux xps-clone (MIT, spencerbull)" +validated_by="dhh" +validated_on="2026-07-24" +## The measurements below were taken on the XPS 14 (0DB9). The XPS 16 (0DBA) is +## covered on report rather than measurement; re-measure there before treating +## these figures as describing it. +validated_hardware="XPS 14 DA14260 (0DB9)" + +## Measured against that reference under a dense pink-weighted multitone of 104 +## bin-aligned tones. See docs/AUDIO-TUNING.md for how to reproduce these. +magnitude_rms_db="1.24" +bass_group_delay_swing_ms="13.2" +limiter_headroom_db="1.6" ## worst-case peak on a hot master vs -1 dBFS +dynamic_range_delta_lu="0.1" diff --git a/default/fontconfig/conf.avail/50-omarchy.conf b/default/fontconfig/conf.avail/50-omarchy.conf index 95f4f508..eecf66e0 100644 --- a/default/fontconfig/conf.avail/50-omarchy.conf +++ b/default/fontconfig/conf.avail/50-omarchy.conf @@ -38,6 +38,34 @@ + + + + ur + + + Noto Nastaliq Urdu + + + + + + + Noto Naskh Arabic + + + system-ui diff --git a/default/hypr/apps/davinci-resolve.lua b/default/hypr/apps/davinci-resolve.lua index ba81e12c..aba9414d 100644 --- a/default/hypr/apps/davinci-resolve.lua +++ b/default/hypr/apps/davinci-resolve.lua @@ -1,2 +1,8 @@ --- DaVinci Resolve dialog focus handling. -o.window(".*[Rr]esolve.*", { float = true, stay_focused = true }) +-- DaVinci Resolve window focus handling. Kept fully opaque: the default +-- translucency distorts colour-critical grading work. +o.window(".*[Rr]esolve.*", { + float = true, + stay_focused = true, + tag = "-default-opacity", + opacity = "1 1", +}) diff --git a/default/hypr/apps/omarchy-shell.lua b/default/hypr/apps/omarchy-shell.lua index efdbc351..117deb85 100644 --- a/default/hypr/apps/omarchy-shell.lua +++ b/default/hypr/apps/omarchy-shell.lua @@ -7,7 +7,7 @@ hl.layer_rule({ match = { namespace = "omarchy-bar" }, no_anim = true, animation -- Launcher, image selector, emojis, clipboard overlays, and keyboard-driven -- panels should pop without compositor layer fades. Panels keep their own -- QML opacity transition for normal open/close, and skip it for panel handoff. -hl.layer_rule({ match = { namespace = "^(omarchy-menu|omarchy-launcher|omarchy-image-selector|omarchy-emojis|omarchy-clipboard|omarchy-keyboard-panel)$" }, no_anim = true, animation = "none" }) +hl.layer_rule({ match = { namespace = "^(omarchy-menu|omarchy-image-selector|omarchy-emojis|omarchy-clipboard|omarchy-keyboard-panel)$" }, no_anim = true, animation = "none" }) -- Dev gallery is the main shell workbench; open it maximized like -- SUPER+ALT+F so component previews have the whole workspace. diff --git a/default/hypr/apps/hyprshot.lua b/default/hypr/apps/screenshot-selection.lua similarity index 53% rename from default/hypr/apps/hyprshot.lua rename to default/hypr/apps/screenshot-selection.lua index dde19803..f4e3b2fd 100644 --- a/default/hypr/apps/hyprshot.lua +++ b/default/hypr/apps/screenshot-selection.lua @@ -1,2 +1,2 @@ --- Remove 1px border around hyprshot screenshots. +-- Remove the 1px border around the slurp region selection used by screenshots. hl.layer_rule({ match = { namespace = "selection" }, no_anim = true, animation = "none" }) diff --git a/default/hypr/bindings/applications.lua b/default/hypr/bindings/applications.lua index 4c51f9dc..e3a7a8e3 100644 --- a/default/hypr/bindings/applications.lua +++ b/default/hypr/bindings/applications.lua @@ -7,33 +7,27 @@ o.bind("SUPER + SHIFT + B", "Browser", { omarchy = "browser" }) o.bind("SUPER + SHIFT + ALT + B", "Browser (private)", { omarchy = "browser --private" }) o.bind("SUPER + SHIFT + N", "Editor", { omarchy = "editor" }) -if not o.preinstalled_bindings_enabled() then - return +if o.preinstalled_bindings_enabled() then + -- Bindings for preinstalled Omarchy applications, TUIs, and web apps. + o.bind("SUPER + ALT + RETURN", "Tmux", { omarchy = "terminal-tmux" }) + o.bind("SUPER + SHIFT + M", "Music", { omarchy = "spotify" }) + o.bind("SUPER + SHIFT + ALT + M", "Music TUI", { tui = "cliamp", focus = true }) + o.bind("SUPER + SHIFT + D", "Docker", { tui = "lazydocker" }) + o.bind("SUPER + SHIFT + G", "Signal", { omarchy = "signal" }) + o.bind("SUPER + SHIFT + O", "Obsidian", { launch = "obsidian", focus = "^obsidian$" }) + o.bind("SUPER + SHIFT + W", "Omawrite", { launch = "omawrite" }) + o.bind("SUPER + SHIFT + SLASH", "Passwords", { omarchy = "1password" }) + + o.bind("SUPER + SHIFT + A", "ChatGPT", { webapp = "https://chatgpt.com" }) + o.bind("SUPER + SHIFT + ALT + A", "Grok", { webapp = "https://grok.com" }) + o.bind("SUPER + SHIFT + C", "Calendar", { webapp = "https://app.hey.com/calendar/weeks/" }) + o.bind("SUPER + SHIFT + E", "Email", { webapp = "https://app.hey.com" }) + o.bind("SUPER + SHIFT + ALT + E", "New email", { webapp = "https://app.hey.com/messages/new?display=standalone&new_window=true" }) + o.bind("SUPER + SHIFT + Y", "YouTube", { webapp = "https://youtube.com/" }) + o.bind("SUPER + SHIFT + ALT + G", "WhatsApp", { webapp = "https://web.whatsapp.com/", focus = true }) + o.bind( "SUPER + SHIFT + CTRL + G", "Google Messages", { webapp = "https://messages.google.com/web/conversations", focus = true }) + o.bind("SUPER + SHIFT + P", "Google Photos", { webapp = "https://photos.google.com/", focus = true }) + o.bind("SUPER + SHIFT + S", "Google Maps", { webapp = "https://maps.google.com/", focus = true }) + o.bind("SUPER + SHIFT + X", "X", { webapp = "https://x.com/" }) + o.bind("SUPER + SHIFT + ALT + X", "X Post", { webapp = "https://x.com/compose/post" }) end - --- Bindings for preinstalled Omarchy applications, TUIs, and web apps. -o.bind("SUPER + ALT + RETURN", "Tmux", { omarchy = "terminal-tmux" }) -o.bind("SUPER + SHIFT + M", "Music", { omarchy = "spotify" }) -o.bind("SUPER + SHIFT + ALT + M", "Music TUI", { tui = "cliamp", focus = true }) -o.bind("SUPER + SHIFT + D", "Docker", { tui = "lazydocker" }) -o.bind("SUPER + SHIFT + G", "Signal", { omarchy = "signal" }) -o.bind("SUPER + SHIFT + O", "Obsidian", { launch = "obsidian", focus = "^obsidian$" }) -o.bind("SUPER + SHIFT + W", "Omawrite", { launch = "omawrite" }) -o.bind("SUPER + SHIFT + SLASH", "Passwords", { omarchy = "1password" }) - -o.bind("SUPER + SHIFT + A", "ChatGPT", { webapp = "https://chatgpt.com" }) -o.bind("SUPER + SHIFT + ALT + A", "Grok", { webapp = "https://grok.com" }) -o.bind("SUPER + SHIFT + C", "Calendar", { webapp = "https://app.hey.com/calendar/weeks/" }) -o.bind("SUPER + SHIFT + E", "Email", { webapp = "https://app.hey.com" }) -o.bind("SUPER + SHIFT + ALT + E", "New email", { webapp = "https://app.hey.com/messages/new?display=standalone&new_window=true" }) -o.bind("SUPER + SHIFT + Y", "YouTube", { webapp = "https://youtube.com/" }) -o.bind("SUPER + SHIFT + ALT + G", "WhatsApp", { webapp = "https://web.whatsapp.com/", focus = true }) -o.bind( - "SUPER + SHIFT + CTRL + G", - "Google Messages", - { webapp = "https://messages.google.com/web/conversations", focus = true } -) -o.bind("SUPER + SHIFT + P", "Google Photos", { webapp = "https://photos.google.com/", focus = true }) -o.bind("SUPER + SHIFT + S", "Google Maps", { webapp = "https://maps.google.com/", focus = true }) -o.bind("SUPER + SHIFT + X", "X", { webapp = "https://x.com/" }) -o.bind("SUPER + SHIFT + ALT + X", "X Post", { webapp = "https://x.com/compose/post" }) diff --git a/default/hypr/bindings/clipboard.lua b/default/hypr/bindings/clipboard.lua index 10a2f6df..ce70fe46 100644 --- a/default/hypr/bindings/clipboard.lua +++ b/default/hypr/bindings/clipboard.lua @@ -1,11 +1,16 @@ --- Work around Hyprland send_shortcut sometimes leaving synthetic key state stuck/repeating. +-- Send with explicit mods to the focused surface by omitting the window target, +-- so universal clipboard shortcuts reach both normal windows and focused +-- layer-shell surfaces such as Omarchy panels. A virtual keyboard (wtype) won't +-- do: the physically held SUPER merges into the injected chord at the seat. +-- The down/up split works around Hyprland send_shortcut sometimes leaving +-- synthetic key state stuck/repeating. -- https://github.com/hyprwm/Hyprland/discussions/14099 local function send_shortcut_once(mods, key) return function() - hl.dispatch(hl.dsp.send_key_state({ mods = mods, key = key, state = "down", window = "activewindow" })) + hl.dispatch(hl.dsp.send_key_state({ mods = mods, key = key, state = "down" })) hl.timer(function() - hl.dispatch(hl.dsp.send_key_state({ mods = mods, key = key, state = "up", window = "activewindow" })) + hl.dispatch(hl.dsp.send_key_state({ mods = mods, key = key, state = "up" })) end, { timeout = 50, type = "oneshot" }) end end diff --git a/default/hypr/bindings/utilities.lua b/default/hypr/bindings/utilities.lua index 8a948ec0..a3b8a743 100644 --- a/default/hypr/bindings/utilities.lua +++ b/default/hypr/bindings/utilities.lua @@ -1,21 +1,17 @@ -o.bind("SUPER + SPACE", "Launch apps", "omarchy-shell shell toggle omarchy.launcher \"{}\"") +o.bind("SUPER + SPACE", "Omarchy menu", "omarchy-menu toggle") o.bind("SUPER + CTRL + E", "Emojis", "omarchy-shell shell toggle omarchy.emojis") o.bind("SUPER + CTRL + C", "Capture menu", "omarchy-menu toggle capture") o.bind("SUPER + CTRL + O", "Toggle menu", "omarchy-menu toggle toggle") o.bind("SUPER + CTRL + H", "Hardware menu", "omarchy-menu toggle hardware") -o.bind("SUPER + ALT + SPACE", "Omarchy menu", "omarchy-menu toggle root") o.bind("SUPER + SHIFT + code:201", "Omarchy menu", "omarchy-menu toggle root") o.bind("SUPER + ESCAPE", "System menu", "omarchy-menu toggle system") o.bind("XF86PowerOff", "Power menu", "omarchy-menu toggle system", { locked = true }) o.bind("SUPER + K", "Show key bindings", "omarchy-menu-keybindings") o.bind("SUPER + ALT + K", "Show Tmux key bindings", "omarchy-menu-tmux-keybindings") +o.bind("SUPER + CTRL + J", "Jump to waiting Tmux pane", "omarchy-tmux-alert focus") o.bind("XF86Calculator", "Calculator", "gnome-calculator") o.bind_toggle("SUPER + SHIFT + SPACE", "Toggle top bar", "bar") -o.bind("SUPER + SHIFT + CTRL + UP", "Move bar to top", "omarchy-bar position top") -o.bind("SUPER + SHIFT + CTRL + DOWN", "Move bar to bottom", "omarchy-bar position bottom") -o.bind("SUPER + SHIFT + CTRL + LEFT", "Move bar to left", "omarchy-bar position left") -o.bind("SUPER + SHIFT + CTRL + RIGHT", "Move bar to right", "omarchy-bar position right") o.bind("SUPER + CTRL + SPACE", "Background switcher", "omarchy-menu toggle background") o.bind("SUPER + SHIFT + CTRL + SPACE", "Theme menu", "omarchy-menu toggle theme") o.bind("SUPER + BACKSPACE", "Toggle window transparency", "omarchy-hyprland-window-transparency-toggle") @@ -33,7 +29,7 @@ o.bind_toggle("SUPER + CTRL + I", "Toggle locking on idle", "idle") o.bind_toggle("SUPER + CTRL + N", "Toggle nightlight", "nightlight") o.bind("SUPER + CTRL + Delete", "Toggle laptop display", "omarchy-hyprland-monitor-internal toggle") o.bind("SUPER + CTRL + ALT + Delete", "Toggle laptop display mirroring", "omarchy-hyprland-monitor-internal-mirror toggle") -o.bind("switch:on:Lid Switch", nil, "omarchy-hyprland-monitor-clamshell", { locked = true }) +o.bind("switch:on:Lid Switch", nil, "omarchy-system-lid-close", { locked = true }) o.bind("switch:off:Lid Switch", nil, "omarchy-hyprland-monitor-clamshell", { locked = true }) o.bind("PRINT", "Screenshot", "omarchy-capture-screenshot") diff --git a/default/hypr/input.lua b/default/hypr/input.lua index 27f3184d..c3c7d43f 100644 --- a/default/hypr/input.lua +++ b/default/hypr/input.lua @@ -21,14 +21,34 @@ local function read_vconsole() return values end +-- Layouts that can't type Latin letters. Keep in sync with the list in +-- etc/mkinitcpio.conf.d/omarchy_hooks.conf. +local non_latin_layouts = + " af am ara bd bg by et ge gr il in iq ir kg kh kz la lk mk mm mn mv np rs ru sy th tj ua " + local vconsole = read_vconsole() +local kb_layout = vconsole.XKBLAYOUT or "us" +local kb_variant = vconsole.XKBVARIANT or "" +local kb_options = "compose:caps,shift:both_capslock" + +-- Hyprland resolves keybindings against the first entry in kb_layout, not the +-- layout that's currently active, so Omarchy's Latin-keysym bindings (SUPER + W +-- and friends) only fire when a Latin layout leads. Installing with a non-Latin +-- one would otherwise leave the desktop unusable. +if non_latin_layouts:find(" " .. kb_layout:match("^[^,]*") .. " ", 1, true) then + kb_layout = "us," .. kb_layout + kb_variant = "," .. kb_variant + -- Reach the original layout with Left Alt + Right Alt. + kb_options = kb_options .. ",grp:alts_toggle" +end + hl.config({ input = { - kb_layout = vconsole.XKBLAYOUT or "us", - kb_variant = vconsole.XKBVARIANT or "", + kb_layout = kb_layout, + kb_variant = kb_variant, kb_model = "", - kb_options = "compose:caps,shift:both_capslock", + kb_options = kb_options, kb_rules = "", follow_mouse = 1, sensitivity = 0, diff --git a/default/hypr/toggles.lua b/default/hypr/toggles.lua index 5f24e1cf..1ca62205 100644 --- a/default/hypr/toggles.lua +++ b/default/hypr/toggles.lua @@ -5,3 +5,5 @@ local toggles_dir = paths.state_home .. "/omarchy/toggles/hypr" package.path = toggles_dir .. "/?.lua;" .. package.path require_all.files(toggles_dir, nil, { reload = true }) + +require("default.hypr.workspace-layouts") diff --git a/default/hypr/workspace-layouts.lua b/default/hypr/workspace-layouts.lua new file mode 100644 index 00000000..6c33184e --- /dev/null +++ b/default/hypr/workspace-layouts.lua @@ -0,0 +1,8 @@ +-- Restore workspace layouts saved by omarchy-hyprland-workspace-layout-toggle. + +local paths = require("default.hypr.paths") +local require_all = require("default.hypr.require_all") + +local layouts_dir = paths.state_home .. "/omarchy/workspace-layouts" + +require_all.files(layouts_dir, "omarchy.workspace-layouts", { reload = true }) diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 024a204e..20134af2 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -14,7 +14,7 @@ // checked shell condition; append ✓ when it succeeds // Root Menu - "apps": {"icon":"󰀻","label":"Apps","aliases":["app","applications"],"action":"omarchy-shell shell summon omarchy.launcher"}, + "apps": {"icon":"󰀻","label":"Apps","aliases":["app","applications"],"provider":"apps"}, "learn": {"icon":"󰧑","label":"Learn"}, "trigger": {"icon":"󱓞","label":"Trigger"}, "style": {"icon":"","label":"Style"}, @@ -44,6 +44,7 @@ "learn.tmux-keybindings": {"icon":"","label":"Tmux","action":"omarchy-menu-tmux-keybindings"}, // Trigger + "trigger.emoji": {"icon":"","label":"Emoji","aliases":["emoji","emojis"],"action":"omarchy-menu-emoji"}, "trigger.reminder": {"icon":"󰢌","label":"Reminder","aliases":["reminder"]}, "trigger.capture": {"icon":"","label":"Capture","aliases":["capture","screenshot","screenrecord","screen-record","screenrecording"]}, "trigger.capture.screenshot": {"icon":"","label":"Screenshot","action":"omarchy-capture-screenshot"}, @@ -54,7 +55,7 @@ "trigger.capture.screenrecord.no-audio": {"icon":"","label":"With no audio","action":"omarchy-capture-screenrecording"}, "trigger.capture.screenrecord.desktop-audio": {"icon":"","label":"With desktop audio","action":"omarchy-capture-screenrecording --with-desktop-audio"}, "trigger.capture.screenrecord.microphone": {"icon":"","label":"With desktop + microphone audio","action":"omarchy-capture-screenrecording --with-desktop-audio --with-microphone-audio"}, - "trigger.capture.screenrecord.webcam": {"icon":"","label":"With desktop + microphone audio + webcam","action":"omarchy-capture-screenrecording-with-webcam"}, + "trigger.capture.screenrecord.webcam": {"icon":"","label":"With desktop + microphone audio + webcam","when":"omarchy-hw-webcam","action":"omarchy-capture-screenrecording-with-webcam"}, "trigger.transcode": {"icon":"󰧸","label":"Transcode","action":"omarchy-transcode"}, "trigger.share": {"icon":"","label":"Share","aliases":["share"]}, "trigger.toggle": {"icon":"󰔎","label":"Toggle","aliases":["toggle","toggles"]}, @@ -120,7 +121,7 @@ "setup.monitors": {"icon":"󰍹","label":"Monitors","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/monitors.lua\""}, "setup.keybindings": {"icon":"","label":"Keybindings","when":"[[ -f ~/.config/hypr/bindings.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/bindings.lua\""}, "setup.input": {"icon":"","label":"Input","when":"[[ -f ~/.config/hypr/input.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/input.lua\""}, - "setup.direct-boot": {"icon":"","label":"Direct Boot","action":"omarchy-launch-floating-terminal-with-presentation omarchy-config-direct-boot"}, + "setup.direct-boot": {"icon":"","label":"Direct Boot","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-direct-boot"}, "setup.default": {"icon":"","label":"Defaults","aliases":["default","defaults"]}, "setup.default.browser": {"icon":"","label":"Browser"}, "setup.default.browser.chromium": {"icon":"","label":"Chromium","when":"omarchy-cmd-present chromium","checked":"[[ \"$(omarchy-default-browser)\" == \"chromium\" ]]","action":"omarchy-default-browser chromium"}, @@ -128,7 +129,7 @@ "setup.default.browser.brave": {"icon":"󰖟","label":"Brave","when":"omarchy-cmd-present brave","checked":"[[ \"$(omarchy-default-browser)\" == \"brave\" ]]","action":"omarchy-default-browser brave"}, "setup.default.browser.brave-origin": {"icon":"󰖟","label":"Brave Origin","when":"omarchy-cmd-present brave-origin","checked":"[[ \"$(omarchy-default-browser)\" == \"brave-origin\" ]]","action":"omarchy-default-browser brave-origin"}, "setup.default.browser.edge": {"icon":"󰇩","label":"Edge","when":"omarchy-cmd-present microsoft-edge-stable","checked":"[[ \"$(omarchy-default-browser)\" == \"edge\" ]]","action":"omarchy-default-browser edge"}, - "setup.default.browser.firefox": {"icon":"󰈹","label":"Firefox","when":"omarchy-cmd-present firefox","checked":"[[ \"$(omarchy-default-browser)\" == \"firefox\" ]]","action":"omarchy-default-browser firefox"}, + "setup.default.browser.firefox": {"icon":"","label":"Firefox","when":"omarchy-cmd-present firefox","checked":"[[ \"$(omarchy-default-browser)\" == \"firefox\" ]]","action":"omarchy-default-browser firefox"}, "setup.default.browser.zen": {"icon":"󰖟","label":"Zen","when":"omarchy-cmd-present zen-browser","checked":"[[ \"$(omarchy-default-browser)\" == \"zen\" ]]","action":"omarchy-default-browser zen"}, "setup.default.terminal": {"icon":"","label":"Terminal"}, "setup.default.terminal.alacritty": {"icon":"","label":"Alacritty","when":"omarchy-cmd-present alacritty","checked":"[[ \"$(omarchy-default-terminal)\" == \"alacritty\" ]]","action":"omarchy-default-terminal alacritty"}, @@ -146,7 +147,7 @@ "setup.default.editor.emacs": {"icon":"","label":"Emacs","when":"omarchy-cmd-present emacs","checked":"[[ \"$(omarchy-default-editor)\" == \"emacs\" ]]","action":"omarchy-default-editor emacs"}, "setup.security": {"icon":"","label":"Security"}, "setup.config": {"icon":"","label":"Config"}, - "setup.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint"}, + "setup.security.fingerprint": {"icon":"󰈷","label":"Fingerprint","when":"omarchy-hw-fingerprint","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint"}, "setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"}, "setup.security.sshd": {"icon":"󰣀","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"}, "setup.security.passwordless-sudo": {"icon":"󰟵","label":"Passwordless Sudo","action":"omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless"}, @@ -189,14 +190,14 @@ "install.editor.sublime": {"icon":"","label":"Sublime Text","action":"omarchy-install-and-launch 'Sublime Text' sublime-text-4 sublime_text"}, "install.editor.helix": {"icon":"","label":"Helix","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-editor-helix"}, "install.editor.vim": {"icon":"","label":"Vim","action":"omarchy-install-app Vim vim"}, - "install.editor.emacs": {"icon":"","label":"Emacs","action":"omarchy-install-app Emacs emacs-wayland && systemctl --user enable --now emacs.service"}, + "install.editor.emacs": {"icon":"","label":"Emacs","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-editor-emacs"}, "install.terminal.alacritty": {"icon":"","label":"Alacritty","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-terminal alacritty'"}, "install.terminal.foot": {"icon":"","label":"Foot","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-terminal foot'"}, "install.terminal.ghostty": {"icon":"","label":"Ghostty","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-terminal ghostty'"}, "install.terminal.kitty": {"icon":"","label":"Kitty","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-install-terminal kitty'"}, "install.ai.dictation": {"icon":"","label":"Dictation","action":"omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install"}, "install.ai.lm-studio": {"icon":"󱚤","label":"LM Studio","action":"omarchy-install-app 'LM Studio' lmstudio-bin"}, - "install.ai.ollama": {"icon":"󱚤","label":"Ollama","action":"if omarchy-cmd-present nvidia-smi; then ollama_pkg=ollama-cuda; elif omarchy-cmd-present rocminfo; then ollama_pkg=ollama-rocm; else ollama_pkg=ollama; fi; install Ollama \"$ollama_pkg\""}, + "install.ai.ollama": {"icon":"󱚤","label":"Ollama","action":"if omarchy-cmd-present nvidia-smi; then ollama_pkg=ollama-cuda; elif omarchy-cmd-present rocminfo; then ollama_pkg=ollama-rocm; else ollama_pkg=ollama; fi; omarchy-install-app Ollama \"$ollama_pkg\""}, "install.ai.crush": {"icon":"󱚤","label":"Crush","action":"omarchy-install-app Crush crush-bin"}, "install.gaming.steam": {"icon":"","label":"Steam","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-steam"}, "install.gaming.retroarch": {"icon":"󰯉","label":"RetroArch","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-gaming-retroarch"}, diff --git a/default/systemd/user/omarchy-migrate-notify.service b/default/systemd/user/omarchy-migrate-notify.service new file mode 100644 index 00000000..79207652 --- /dev/null +++ b/default/systemd/user/omarchy-migrate-notify.service @@ -0,0 +1,16 @@ +[Unit] +Description=Notify about pending Omarchy migrations +# Login-only. There used to be an omarchy-update-user-notify.path watching +# /usr/share/omarchy/migrations, but pacman writes that directory during every +# update -- including the blessed `omarchy update`, which runs omarchy-migrate +# itself a step later -- so the watcher notified about migrations that were +# already being applied in the visible update terminal. Checking once per login +# is the only trigger that cannot collide with a running update. +ConditionPathIsDirectory=/usr/share/omarchy/migrations + +[Service] +Type=oneshot +ExecStart=/usr/bin/omarchy-migrate-notify + +[Install] +WantedBy=graphical-session.target diff --git a/default/systemd/user/omarchy-speaker-tuning.service b/default/systemd/user/omarchy-speaker-tuning.service new file mode 100644 index 00000000..b26ce758 --- /dev/null +++ b/default/systemd/user/omarchy-speaker-tuning.service @@ -0,0 +1,32 @@ +[Unit] +Description=Omarchy speaker tuning filter-chain +Documentation=https://github.com/basecamp/omarchy/blob/master/docs/AUDIO-TUNING.md +# WirePlumber does the linking, so starting before it is up risks the output being +# linked before the speaker device has been discovered. +After=pipewire.service wireplumber.service +Requires=pipewire.service +Wants=wireplumber.service +# Restart with the audio daemon, since the filter-chain loses its connection when +# PipeWire goes away. +PartOf=pipewire.service + +[Service] +Type=simple +# Hosts the tuning as a PipeWire *client* rather than loading it into the daemon +# from pipewire.conf.d, which is only read at daemon startup. That is what lets +# the tuning be switched on and off without restarting pipewire-pulse -- a +# restart drops every PulseAudio client's connection, and applications that do +# not reconnect (Spotify) have to be restarted by hand. +# +# It also contains failure: a malformed tuning breaks only this service, where a +# bad drop-in in the daemon's own config stops PipeWire from starting at all. +# +# The config name is deliberately not PipeWire's stock filter-chain.conf, which +# merges every fragment in ~/.config/pipewire/filter-chain.conf.d/ and would make +# this service host unrelated user filters too. +ExecStart=/usr/bin/pipewire -c omarchy-speaker-tuning.conf +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=graphical-session.target diff --git a/default/systemd/user/omarchy-update-user-notify.path b/default/systemd/user/omarchy-update-user-notify.path deleted file mode 100644 index aaf5233f..00000000 --- a/default/systemd/user/omarchy-update-user-notify.path +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=Watch for Omarchy migrations - -[Path] -# Edge-triggered watch only. PathExistsGlob= is level-triggered: it re-fires -# every time the triggered unit deactivates for as long as the glob matches, -# and applied migrations stay on disk forever, so a glob here busy-loops the -# oneshot service. The once-per-login check lives in the service's own -# WantedBy=graphical-session.target instead. -PathModified=/usr/share/omarchy/migrations -Unit=omarchy-update-user-notify.service - -[Install] -WantedBy=graphical-session.target diff --git a/default/systemd/user/omarchy-update-user-notify.service b/default/systemd/user/omarchy-update-user-notify.service deleted file mode 100644 index 92f25a65..00000000 --- a/default/systemd/user/omarchy-update-user-notify.service +++ /dev/null @@ -1,10 +0,0 @@ -[Unit] -Description=Notify about pending Omarchy migrations -ConditionPathIsDirectory=/usr/share/omarchy/migrations - -[Service] -Type=oneshot -ExecStart=/usr/bin/omarchy-migrate-notify - -[Install] -WantedBy=graphical-session.target diff --git a/default/systemd/zram-generator.conf.d/90-omarchy.conf b/default/systemd/zram-generator.conf.d/90-omarchy.conf new file mode 100644 index 00000000..77d1add7 --- /dev/null +++ b/default/systemd/zram-generator.conf.d/90-omarchy.conf @@ -0,0 +1,19 @@ +# Compressed swap in RAM, sized the way Fedora has shipped it since F34: all of +# memory, capped at 8G. Fedora weighed the worry that incompressible pages would +# tie up RAM against five years of desktops reporting no trouble from it, which +# is more field evidence than we could gather on our own. +# +# The cap is what bounds the downside. A browser-heavy desktop can drive zstd +# down near 1.1:1, where the device backs far less than it advertises, and 8G +# keeps that bounded on any machine large enough to notice. The disk swapfile +# sits behind it at pri=0 for everything past that. +# +# Fedora spells this zram-fraction = 1.0 with max-zram-size = 8192; both keys +# are deprecated in favour of zram-size, which computes the same numbers. +[zram0] +zram-size = min(ram, 8192) +compression-algorithm = zstd + +# Above the pri=0 that omarchy-hibernation-setup gives the disk swapfile, so +# zram absorbs everything until it's full. +swap-priority = 100 diff --git a/default/tensaku/state.toml b/default/tensaku/state.toml new file mode 100644 index 00000000..42390d41 --- /dev/null +++ b/default/tensaku/state.toml @@ -0,0 +1 @@ +annotation-size-factor = 2.0 diff --git a/default/themed/waybar.css.tpl b/default/themed/waybar.css.tpl deleted file mode 100644 index d876a922..00000000 --- a/default/themed/waybar.css.tpl +++ /dev/null @@ -1,2 +0,0 @@ -@define-color foreground {{ foreground }}; -@define-color background {{ background }}; diff --git a/docs/AUDIO-TUNING.md b/docs/AUDIO-TUNING.md new file mode 100644 index 00000000..8f24917a --- /dev/null +++ b/docs/AUDIO-TUNING.md @@ -0,0 +1,141 @@ +# Speaker tunings + +Laptop speakers ship voiced by the vendor's Windows DSP layer, which Linux does +not get. A tuning restores that as a PipeWire filter-chain in front of the +internal speaker sink: a declarative graph hosted by a small PipeWire client, with +no GUI app and no binary blob. + +``` +default/audio/tunings/-/ +├── tuning.conf # description, match, provenance, measurements +└── filter-chain.conf # the graph, with @SPEAKER_SINK@ substituted on install +``` + +`on` renders the graph into `~/.config/pipewire/omarchy-speaker-tuning.conf.d/` +and runs it as its own PipeWire client via `omarchy-speaker-tuning.service`, rather than +loading it into the audio daemon. The daemon only reads its own config at startup, +so a daemon-loaded tuning could only be switched by restarting PipeWire — which +drops every PulseAudio client's connection, and applications that do not reconnect +(Spotify) then have to be restarted by hand. Hosting it separately makes switching +a start/stop of one small process, and contains failure: a malformed tuning breaks +only that service instead of stopping PipeWire from starting at all. + +Tunings apply automatically: `install/hardware/speaker-tuning.sh` installs the +LV2 dependency and `install/user/first-run/audio-tuning.sh` applies the tuning, +both gated on the match. Machines without a matching tuning are untouched. + +Switching it on happens at first-run, not at finalize-user time, because finalize-user +also runs in the ISO chroot where there is no audio server: the sink a tuning has +to target does not exist there, so nothing could be written — and nothing would +retry, since the finalizer marks all shipped migrations complete on a fresh +install. Matching itself deliberately does not consult the audio graph, so the +LV2 dependency is still installed in the chroot. + +```bash +omarchy audio tuning on # install the matching tuning +omarchy audio tuning off # remove it, back to raw speakers +omarchy audio tuning status # installed? in use? what matches? +``` + +`match` and `fronted-sink` are also accepted; they exist for the install hooks +and the sink-listing scripts rather than for daily use. + +## Adding a tuning + +Add a directory with a `tuning.conf` and a `filter-chain.conf`. No new command is +needed: matching is data. A tuning declares how to recognise its hardware, plus the +`sink_pattern` its graph targets: + +| Key | Matches on | Notes | +|---|---|---| +| `match_sku` | DMI product SKU, whole value | Most precise. Vendors key speaker firmware on it, so it identifies the hardware rather than a marketing name | +| `match_dmi` | DMI product name or family, substring | Convenient, but a short string widens fast — `product_family` here is `Dell Laptops` | +| `match_command` | Any predicate you name | For hardware needing a sharper test than either | + +`match_sku` and `match_dmi` are lists, so one tuning can cover several models it has +been validated on: + +```bash +match_sku=("0DB9" "0DBA") # XPS 14 and XPS 16 +``` + +`sink_pattern` is required whichever method you use, since the graph's target sink +is substituted from it. + +Gate narrowly and widen as models are validated; a tuning aimed at the wrong +drivers can sound worse than none and can stress them. When a tuning covers a model +it was not measured on, say so in `tuning.conf` — the provenance fields are there to +keep that distinction visible rather than implied. + +Two hard requirements: + +- **End in a limiter.** Peaks must stay under 0 dBFS with headroom. +- **Do not boost what the drivers cannot deliver.** The XPS 14 tuning + deliberately *cuts* 40 Hz by around 18 dB. Excursion down there buys nothing + and costs distortion. + +## Building one + +Measuring a laptop and fitting a filter-chain to it is a separate job with its own +tools, in [omarchy-audio-tuner](https://github.com/omacom-io/omarchy-audio-tuner). +It is not installed by default — almost nobody authoring a tuning, and it needs +python, ffmpeg and mpv. + +```bash +omarchy pkg add omarchy-audio-tuner +``` + +Its README is the walkthrough, and covers both cases: copying a reference that +already sounds right (how the XPS 14 tuning was made, no microphone needed), and +designing from scratch, which needs a *calibrated* measurement mic and a target +curve that measurement alone cannot give you. + +## What a tuning must report + +A tuning is not reviewable on "sounds better to me". Record these, measured, in +`tuning.conf`: + +| Field | What it is | +|---|---| +| `magnitude_rms_db` | Deviation from the reference or target it was fitted to | +| `bass_group_delay_swing_ms` | Max minus min group delay, 30–300 Hz | +| `limiter_headroom_db` | Worst-case peak against the limiter threshold | +| `dynamic_range_delta_lu` | LRA change against the reference | + +Measure electrically by capturing the physical speaker sink's monitor, which sits +upstream of the volume control, so results are independent of listening level. + +## How this fits the audio graph + +Two things about the surrounding system are worth knowing, because both caused +real bugs: + +- **Volume lives downstream of the tuning.** A tuning is a virtual sink that + becomes the default output, and changing *its* volume would alter the level + going into the processing — moving the display while the speakers stay put, and + changing the tone of anything with a compressor or limiter in it. + `omarchy-audio-output-sink` is the single definition of "which sink does this + output's volume really use": it resolves a sink through any DSP sink to the + physical one, and with no argument resolves the current default output. The + volume keys, the output switcher's OSD and the audio panel all use it, so they + cannot disagree. Resolving the *current default* rather than "whatever a tuning + fronts" is what keeps it correct when headphones or HDMI are selected while a + tuning still exists. +- **The fronted sink is hidden.** The tuning and the physical speakers both exist + in the graph, and selecting the physical one would only bypass the tuning. So + `omarchy-audio-sink-availability` reports it unavailable and + `omarchy-audio-output-switch` skips it, leaving one speaker entry in the panel. + A WirePlumber smart filter would remove the need for both — it leaves the real + device as the default output — but on PipeWire 1.6.8 / WirePlumber 0.5.15 the + graph loads and links correctly as a smart filter and then passes audio through + unprocessed. Revisit when that is understood. +- **EasyEffects cannot coexist with a tuning.** It moves any stream that follows + the default sink to its own sink, so it would grab audio back from a + filter-chain. `on` refuses while it is running rather than installing a graph + that would be bypassed. +- **The tuning's own output must not be moved.** A filter-chain's output is a + playback stream like any other, so anything rerouting "all streams" to a newly + selected output would drag the processing with it — onto headphones, or into the + tuning's own sink, which is a cycle. The tuning sets `node.dont-move`, and + `omarchy-audio-output-set-default` moves only streams that carry an + `application.name`. diff --git a/docs/file-layout.md b/docs/file-layout.md index 9be59863..09065807 100644 --- a/docs/file-layout.md +++ b/docs/file-layout.md @@ -97,6 +97,7 @@ default/** ──► omarchy-settings /usr/share/omarchy │ → /etc/skel/.bashrc (post_install cp -f) ├─ hypr/toggles/flags.lua /etc/skel/.local/state/omarchy/toggles/hypr/ ├─ nautilus-python/extensions/*.py /etc/skel/.local/share/nautilus-python/extensions/ + ├─ tensaku/state.toml /etc/skel/.local/state/tensaku/state.toml ├─ uwsm/env.d/10-omarchy /usr/share/uwsm/env.d/ ├─ environment.d/*.conf /usr/lib/environment.d/ ├─ fontconfig/conf.avail/50-omarchy.conf /usr/share/fontconfig/conf.avail/ @@ -105,6 +106,7 @@ default/** ──► omarchy-settings /usr/share/omarchy ├─ applications/mimeapps.list /usr/share/applications/mimeapps.list ├─ systemd/user/*.{service,path} /usr/lib/systemd/user/ ├─ systemd/system-sleep/unmount-fuse /usr/lib/systemd/system-sleep/ + ├─ systemd/zram-generator.conf.d/90-omarchy.conf /usr/lib/systemd/zram-generator.conf.d/ ├─ fonts/omarchy/omarchy.ttf /usr/share/fonts/omarchy/ ├─ sddm/omarchy/ /usr/share/sddm/themes/omarchy/ ├─ sddm/hyprland.lua /usr/share/sddm/hyprland.lua @@ -195,13 +197,19 @@ migration. Migrations run as the user; privileged work should invoke the appropriate helper or privilege prompt. Migrations must be idempotent; machine-wide repairs should no-op when another user already applied them. -Each graphical user has `omarchy-update-user-notify.path` watching the packaged -migration directory for changes, and `omarchy-update-user-notify.service` is -also started once per login via its own `WantedBy=graphical-session.target`. -Either way the service runs `omarchy-migrate-notify` as that user. The notifier checks -`omarchy-migrate --pending`. If this user has missing migration state, it shows a -notification that opens a terminal for `omarchy-migrate`. The notifier never runs -migrations in the background. +Each graphical user has `omarchy-migrate-notify.service`, started once per login +through `WantedBy=graphical-session.target`. The package also ships +`omarchy-update-user-notify.service` as a symlink onto it, so users enabled +under the old unit name keep working before they reach migration `1785095882`. +It runs `omarchy-migrate-notify` as +that user, which checks `omarchy-migrate --pending`. If this user has missing +migration state, it shows a notification that opens a terminal for +`omarchy-migrate`. The notifier never runs migrations in the background. + +Login is the only trigger. Nothing watches the packaged migration directory: a +watcher cannot tell a bypassed `pacman -Syu` from the package transaction inside +a normal `omarchy update`, so it notified about migrations that `omarchy-migrate` +was already applying in the visible update terminal. `omarchy-migrate` waits for any active pacman transaction to finish, then runs pending migrations. It does not need `--force`; migrations happen when state @@ -219,8 +227,8 @@ systemd instance: Voxtype post-update hook. - `install/user/first-run/enable-user-units.sh` — `systemctl --user enable` the shipped user units (`bt-agent`, `omarchy-sleep-lock`, - `omarchy-recover-internal-monitor`, `omarchy-update-user-notify.path`, - `omarchy-update-user-notify.service`). Done here, not at finalize, because + `omarchy-recover-internal-monitor`, `omarchy-migrate-notify.service`). + Done here, not at finalize, because the user manager isn't reachable from the ISO chroot; `ConditionPath*` in the unit files keeps services inert when they don't apply. - `install/user/first-run/gnome-theme.sh`, @@ -247,14 +255,14 @@ the legacy finalization marker from `~/.local/state/omarchy/` into `done/`. `omarchy-setup-system` (root, in chroot) runs target-side setup at ISO finalization. It sources: -- `install/config/*.sh` — theme links, lockout limits, lockscreen PAM, +- `install/config/all.sh` — theme links, lockout limits, lockscreen PAM, powerprofilesctl shebang fix, docker setup, Snapper retention, locate index tuning, service enablement, firewall. - `install/hardware/all.sh` via `omarchy-setup-hardware` — vendor- and device-specific kernel modules, udev rules, microcode, wireless regdom, ASUS / Framework / Intel / Apple / Lenovo quirks. -- `install/login/*.sh` — SDDM theme/session config. -- `install/post-install/*.sh` — final pacman/udev/localdb passes. +- `install/login/all.sh` — SDDM theme/session config. +- `install/post-install/all.sh` — final pacman/udev/localdb passes. Logging goes to `/var/log/omarchy-install.log` via `install/helpers/logging.sh`. @@ -288,8 +296,9 @@ return to the packaged default. | Package-owned system file (e.g. systemd user service/path in `/usr/lib`) | `default/`, document the mapping in `default/package-defaults.tsv`, then add the `install -Dm644` line in `omarchy-settings` PKGBUILD | | Per-user file that's static but lives outside `~/.config` | `default/`, then add `install -Dm644 ... $pkgdir/etc/skel/...` in `omarchy-settings` PKGBUILD | | Runtime tweak that needs `$HOME` or live system state | extend `omarchy-finalize-user`, or add a per-user leaf under `install/user/` and wire into `install/user/all.sh` | -| One-time root-side setup step | `install/config/*.sh` or `install/hardware/*.sh`, wire into `omarchy-setup-system` or `install/hardware/all.sh` | +| One-time root-side setup step | `install/config/*.sh` or `install/hardware/*.sh`, wire into `install/config/all.sh` or `install/hardware/all.sh` | | One-time fix for existing installs | `migrations/.sh` | +| Package-owned path something else may already write | Prefer a path nothing else writes, such as a vendor drop-in under `/usr/lib`. Otherwise the `--overwrite` entry in `bin/omarchy-update-system-pkgs` has to ship a release before the file | | User-facing `omarchy-*` command | `bin/omarchy--` — see `GROUP_DESCRIPTIONS` in `bin/omarchy` | | New stock theme | `themes//` (+ matching templates under `default/themed/` if they need theme colors) | | User-installed theme | `~/.config/omarchy/themes//` | diff --git a/docs/migrations.md b/docs/migrations.md index 48c2125b..7e6d5538 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -43,16 +43,9 @@ omarchy-hook post-update `omarchy-migrate` waits for any active pacman transaction to finish, then runs all pending migrations for the current user in the visible update terminal. -### During direct pacman updates +### At login -Raw `sudo pacman -Syu` is guarded. Users should normally run: - -```bash -omarchy update -``` - -If a user explicitly bypasses the guard, user sessions watch the packaged -migration directory and run a notifier. The notifier checks: +Every graphical login starts `omarchy-migrate-notify.service`, which checks: ```bash omarchy-migrate --pending @@ -67,6 +60,15 @@ omarchy-migrate The notifier never runs migrations silently in the background. +This is what covers users who did not run the update themselves: someone who +bypassed the pacman guard with `sudo env OMARCHY_ALLOW_DIRECT_PACMAN=1 pacman +-Syu`, and any second user on the machine, whose migration markers are per-user +and therefore still missing after another user updated. + +Login is the only trigger on purpose. Watching the packaged migration directory +also fires during a normal `omarchy update`, which prompts for migrations that +`omarchy-migrate` is about to run in the visible update terminal. + ### Manually Users can safely run: diff --git a/docs/update-process.md b/docs/update-process.md index 5581879c..b12f4f51 100644 --- a/docs/update-process.md +++ b/docs/update-process.md @@ -118,6 +118,7 @@ omarchy-update ├─ create snapper snapshot, if snapper is installed └─ run update pipeline ├─ block system sleep and temporarily enable shell stay-awake mode + ├─ omarchy-update-dev ├─ omarchy-update-keyring ├─ omarchy-update-system-pkgs ├─ omarchy-migrate @@ -133,6 +134,8 @@ omarchy-update Important behavior: +- In dev-link mode, `omarchy update` fast-forwards the active checkout from its + configured upstream before changing system packages or running migrations. - `omarchy update` checks/runs migrations in the same visible terminal via `omarchy-migrate` after pacman finishes. - A failure should leave enough output in `/tmp/omarchy-update.log` and the @@ -146,20 +149,37 @@ High-level flow: sudo pacman -Syu ├─ pre-transaction guard aborts and tells the user to run omarchy update └─ if explicitly bypassed, upgrades omarchy and related packages - └─ user session notices migration directory changes - ├─ omarchy-update-user-notify.path triggers, if enabled + └─ at that user's next login + ├─ omarchy-migrate-notify.service starts with graphical-session.target ├─ omarchy-migrate-notify checks omarchy-migrate --pending ├─ if this user has missing migration state, show notification └─ click opens terminal: omarchy-migrate ``` +Login is deliberately the only trigger. A watcher on the packaged migration +directory cannot distinguish a bypassed `pacman -Syu` from the package +transaction inside a normal `omarchy update`, so it fired notifications for +migrations that `omarchy-migrate` was about to apply in the visible update +terminal. The retired unit was `omarchy-update-user-notify.path`. + Fallbacks: -- `omarchy-first-run` enables the user notification path unit. -- `omarchy-first-run` also invokes `omarchy-migrate-notify` on graphical - startup, so users who updated before the path unit existed still get prompted - if they have missing migration state. +- `omarchy-first-run` enables `omarchy-migrate-notify.service`, which also + covers users created after install: their per-user migration markers are + missing, so their first login prompts them to run every shipped migration. +- The package ships `omarchy-update-user-notify.service` as a symlink onto + `omarchy-migrate-notify.service`. Users set up before the rename hold an + absolute `graphical-session.target.wants` symlink to the old path, and the + migration that repoints it only runs for users who run an update — the + opposite of who the notifier is for. The alias can be dropped once installs + have run migration `1785095882`. +- The notifier waits for a live notification server before sending, because + `graphical-session.target` can be reached before the shell claims + `org.freedesktop.Notifications`. - The notifier is only a prompt. It does not run migrations in the background. +- A session that is already open when another user updates is not re-checked; + it picks the migrations up at its next login, or whenever that user runs + `omarchy-migrate` or `omarchy update`. - Direct pacman updates do not run `omarchy-hook post-update` unless the user explicitly runs that hook; without a package-update marker, the only pending state we can derive is missing per-user migration markers. @@ -172,11 +192,16 @@ The bar widget `omarchy.system-update` runs: omarchy-update-available ``` -`omarchy-update-available` checks the installed Omarchy package for updates: +`omarchy-update-available` checks the active Omarchy sources for updates: +- new upstream commits for the active dev-linked checkout - `omarchy-dev`, when installed - otherwise `omarchy`, when installed +The dev check fetches the checkout's configured upstream before comparing it +with `HEAD`. A failed fetch is quiet and falls back to the existing remote- +tracking state. + Exit codes: - `0` — Omarchy updates are available; stdout is the update list. @@ -196,11 +221,12 @@ scripts. | `omarchy-update` | Public user command. Adds transcript logging, lock, confirmation, snapshot, sleep/idle inhibitors, package updates, migrations, hooks, update-state refresh, and restart checks. | **Keep.** This is the blessed entry point and owns the update pipeline. | | `omarchy-update-perform` | Hidden compatibility wrapper for `omarchy-update -y`. | **Temporary.** Keep only for old callers; new code should call `omarchy-update` directly. | | `omarchy-update-confirm` | Gum confirmation copy for `omarchy update`. | **Question.** Could be inlined into `omarchy-update`; separate file only helps keep copy isolated. | +| `omarchy-update-dev` | Fast-forwards the active dev-linked checkout from its configured upstream; no-ops for package-backed installs. | **Keep.** Runs before package updates so a checkout conflict stops the update before system mutation. | | `omarchy-update-keyring` | Ensures Omarchy keyring and Arch keyring are current before the main transaction. | **Keep, but review.** It uses targeted `pacman -Sy` for keyring bootstrapping; acceptable for this special case but should remain tightly scoped. | | `omarchy-update-system-pkgs` | Runs `sudo env OMARCHY_UPDATE_PACMAN=1 pacman -Syu --noconfirm` with targeted transition `--overwrite` entries so the ALPM guard allows the transaction and early package-layout conflicts are handled. | **Keep for now.** Small leaf command, clear/testable. | | `omarchy-migrate` | Public migration command. Waits for pacman, then runs all pending migrations for the current user. Supports `--pending`. | **Keep.** This replaces the discarded `omarchy-update-user-finalize` name and no longer needs `--force`. | | `omarchy-update-pacman-guard` | ALPM pre-transaction guard that aborts direct `pacman -Syu` style upgrades unless Omarchy set `OMARCHY_UPDATE_PACMAN=1` or the user explicitly set `OMARCHY_ALLOW_DIRECT_PACMAN=1`. | **Keep internal/hidden.** This is what nudges users back to `omarchy update`. | -| `omarchy-migrate-notify` | Internal notification helper for direct pacman updates. Uses `omarchy-migrate --pending` and shows notification only when this user has pending migrations. | **Keep internal/hidden.** Clear name now that the public command is `omarchy-migrate`. | +| `omarchy-migrate-notify` | Internal login-time notification helper. Uses `omarchy-migrate --pending` and shows a notification only when this user has pending migrations. | **Keep internal/hidden.** Clear name now that the public command is `omarchy-migrate`. | | `omarchy-update-user-notify` | Hidden compatibility wrapper for `omarchy-migrate-notify`. | **Temporary.** Keep only for old callers. | | `omarchy-update-available` | Update checker for shell widget and post-update refresh. | **Keep.** Could eventually be renamed `omarchy-update-check`, but current name matches widget semantics. | | `omarchy-update-aur-pkgs` | Updates AUR packages with `yay -Sua` if foreign packages exist and AUR is reachable. | **Question.** Omarchy is package-backed now, but users may still install AUR packages. Keep for now. | @@ -220,7 +246,8 @@ scripts. idempotent when they repair machine-wide state. 2. **Migration notification naming** - - The real helper is `omarchy-migrate-notify`. + - The real helper is `omarchy-migrate-notify`, started by + `omarchy-migrate-notify.service`. - `omarchy-update-user-notify` remains only as a hidden compatibility wrapper. 3. **Update pipeline ownership** diff --git a/etc/NetworkManager/conf.d/omarchy-wifi-powersave.conf b/etc/NetworkManager/conf.d/omarchy-wifi-powersave.conf new file mode 100644 index 00000000..454400d3 --- /dev/null +++ b/etc/NetworkManager/conf.d/omarchy-wifi-powersave.conf @@ -0,0 +1,5 @@ +# Keep Wi-Fi power save off: it trades 20-300ms latency spikes on idle links +# for a fraction of a watt while the radio idles, and broken firmware (Intel +# BE200/BE211) drops the link outright when it naps. +[connection] +wifi.powersave = 2 diff --git a/etc/limine-entry-tool.d/omarchy-defaults.conf b/etc/limine-entry-tool.d/omarchy-defaults.conf index 798d0360..49c6204b 100644 --- a/etc/limine-entry-tool.d/omarchy-defaults.conf +++ b/etc/limine-entry-tool.d/omarchy-defaults.conf @@ -2,6 +2,13 @@ TARGET_OS_NAME="Omarchy" KERNEL_CMDLINE[default]+=" quiet splash loglevel=0 systemd.show_status=false rd.udev.log_level=0 vt.global_cursor_default=0" +# Kernel 7.1 unpacks the initramfs asynchronously, which races /init: the +# early /proc, /sys, /dev, and /run mounts fail while unpacking settles, so +# plymouthd exits when it can't read /proc/cmdline and encrypted boots fall +# back to an unthemed text LUKS prompt. Unpack synchronously until the race +# is fixed upstream. +KERNEL_CMDLINE[default]+=" initramfs_async=0" + CUSTOM_UKI_NAME="omarchy" ENABLE_LIMINE_FALLBACK=yes diff --git a/etc/sysctl.d/99-omarchy-sysctl.conf b/etc/sysctl.d/99-omarchy-sysctl.conf index b8845467..d5ae09ea 100644 --- a/etc/sysctl.d/99-omarchy-sysctl.conf +++ b/etc/sysctl.d/99-omarchy-sysctl.conf @@ -1,2 +1,23 @@ # Solve common flakiness with SSH (MTU discovery on flaky links). net.ipv4.tcp_mtu_probing=1 + +# Tune reclaim for swap on zram, which is orders of magnitude faster than the +# disk swapfile these defaults assume. + +# Anything above 100 tells the kernel that evicting an anonymous page is +# cheaper than dropping a page-cache page it would have to re-read from disk. +# With a compressed RAM device that is true, so the disk-era default of 60 +# leaves the page cache starved. +vm.swappiness=180 + +# Read one page per swap-in fault. The default of 8 pays for a seek that zram +# doesn't have, and every extra page costs a separate decompression. +vm.page-cluster=0 + +# Don't let external fragmentation raise the watermarks, which produces +# reclaim bursts while memory is still free. +vm.watermark_boost_factor=0 + +# Keep ~1.25% of memory free instead of 0.1%, so kswapd reclaims in the +# background rather than letting allocations stall in direct reclaim. +vm.watermark_scale_factor=125 diff --git a/etc/systemd/logind.conf.d/20-inhibit-delay.conf b/etc/systemd/logind.conf.d/20-inhibit-delay.conf new file mode 100644 index 00000000..bebd95ee --- /dev/null +++ b/etc/systemd/logind.conf.d/20-inhibit-delay.conf @@ -0,0 +1,10 @@ +# omarchy-sleep-lock.service holds a delay inhibitor so the session is locked +# before the machine suspends. A delay inhibitor is a timer, not a promise: +# logind suspends anyway once the window expires, locked or not. Five seconds +# is not enough when closing the lid also reconfigures displays, because +# Quickshell waits for the screen set to settle before it can secure. +# +# This only costs anything when locking is broken. A healthy lock releases the +# inhibitor the moment the session reports secure, well under a second. +[Login] +InhibitDelayMaxSec=15 diff --git a/etc/udev/rules.d/99-omarchy-wifi-powersave.rules b/etc/udev/rules.d/99-omarchy-wifi-powersave.rules deleted file mode 100644 index 87950af6..00000000 --- a/etc/udev/rules.d/99-omarchy-wifi-powersave.rules +++ /dev/null @@ -1,2 +0,0 @@ -SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="0", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-wifi-powersave-on /usr/bin/omarchy-wifi-powersave on" -SUBSYSTEM=="power_supply", ATTR{type}=="Mains", ATTR{online}=="1", RUN+="/usr/bin/systemd-run --no-block --collect --unit=omarchy-wifi-powersave-off /usr/bin/omarchy-wifi-powersave off" diff --git a/install/hardware/all.sh b/install/hardware/all.sh index 2ad7f096..9a9f1098 100644 --- a/install/hardware/all.sh +++ b/install/hardware/all.sh @@ -15,8 +15,12 @@ run_logged "$OMARCHY_INSTALL/hardware/vulkan.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/video-acceleration.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/lpmd.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/thermald.sh" -run_logged "$OMARCHY_INSTALL/hardware/intel/ipu7-camera.sh" +# Swap in the Panther Lake kernel before anything pulls DKMS modules in. +# intel-ipu7-camera drags in ipu7-drivers, vision-drivers and v4l2loopback, +# and building all three against the stock kernel only to rebuild them against +# linux-ptl and tear the first set down again cost ~25s of the install. run_logged "$OMARCHY_INSTALL/hardware/intel/ptl-kernel.sh" +run_logged "$OMARCHY_INSTALL/hardware/intel/ipu7-camera.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/fred.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/fix-wifi7-eht.sh" run_logged "$OMARCHY_INSTALL/hardware/intel/sof-firmware.sh" @@ -38,4 +42,5 @@ run_logged "$OMARCHY_INSTALL/hardware/fix-bcm43xx.sh" run_logged "$OMARCHY_INSTALL/hardware/fix-surface-keyboard.sh" run_logged "$OMARCHY_INSTALL/hardware/fix-yt6801-ethernet-adapter.sh" run_logged "$OMARCHY_INSTALL/hardware/fix-tuxedo-backlight.sh" +run_logged "$OMARCHY_INSTALL/hardware/speaker-tuning.sh" run_logged "$OMARCHY_INSTALL/hardware/pacman.sh" diff --git a/install/hardware/intel/ptl-kernel.sh b/install/hardware/intel/ptl-kernel.sh index 65de3c3b..79482724 100644 --- a/install/hardware/intel/ptl-kernel.sh +++ b/install/hardware/intel/ptl-kernel.sh @@ -5,10 +5,20 @@ if omarchy-hw-match "XPS" && omarchy-hw-intel-ptl; then echo "Detected Dell XPS Panther Lake, installing PTL kernel..." omarchy-pkg-add linux-ptl linux-ptl-headers - pacman -Rdd --noconfirm linux linux-headers 2>/dev/null || true + pacman -Rdd --noconfirm linux linux-headers || true + + # linux-ptl doesn't provide=linux, so anything depending on linux drags the + # stock kernel back in and the boot menu grows a second, slower entry. + if pacman -Qq linux &>/dev/null; then + echo "WARNING: stock linux kernel still installed alongside linux-ptl:" + pacman -Qi linux | grep -i "required by" + fi mkdir -p /etc/limine-entry-tool.d - cat > /etc/limine-entry-tool.d/dell-xps-panther-lake.conf <<'EOF' + # Named to sort after omarchy-defaults.conf: drop-ins are read in order and + # the last BOOT_ORDER wins, so an earlier-sorting name is a silent no-op. + rm -f /etc/limine-entry-tool.d/dell-xps-panther-lake.conf + cat > /etc/limine-entry-tool.d/zz-dell-xps-panther-lake.conf <<'EOF' # Only show Panther Lake kernel in boot menu on Dell XPS Panther Lake BOOT_ORDER="linux-ptl*, *fallback, Snapshots" EOF diff --git a/install/hardware/speaker-tuning.sh b/install/hardware/speaker-tuning.sh new file mode 100644 index 00000000..57a557f9 --- /dev/null +++ b/install/hardware/speaker-tuning.sh @@ -0,0 +1,10 @@ +# Install the LV2 plugins that shipped speaker tunings need. +# +# Every tuning ends in a lookahead limiter, which is an LV2 plugin. Without it +# the filter-chain graph fails to instantiate and the tuning sink silently never +# appears, so the package is a hard requirement wherever a tuning applies. It is +# only pulled in on machines that have one. + +if omarchy-audio-tuning match >/dev/null; then + omarchy-pkg-add lsp-plugins-lv2 +fi diff --git a/install/helpers/logging.sh b/install/helpers/logging.sh index e325d83e..1ba6edba 100644 --- a/install/helpers/logging.sh +++ b/install/helpers/logging.sh @@ -51,20 +51,17 @@ run_logged() { ;; esac + local runner=(bash -eE) + if [[ ${OMARCHY_INSTALL_DEBUG:-} == "1" ]]; then + runner=(bash -x -eE) + fi + if omarchy_log_to_stdout; then - if [[ ${OMARCHY_INSTALL_DEBUG:-} == "1" ]]; then - PS4='+ ${BASH_SOURCE[0]##*/}:${LINENO}:${FUNCNAME[0]:-main}: ' \ - bash -x -eE -c 'source "$1"' bash "$script" &1 - else - bash -eE -c 'source "$1"' bash "$script" &1 - fi + PS4='+ ${BASH_SOURCE[0]##*/}:${LINENO}:${FUNCNAME[0]:-main}: ' \ + "${runner[@]}" -c 'source "$1"' bash "$script" &1 else - if [[ ${OMARCHY_INSTALL_DEBUG:-} == "1" ]]; then - PS4='+ ${BASH_SOURCE[0]##*/}:${LINENO}:${FUNCNAME[0]:-main}: ' \ - bash -x -eE -c 'source "$1"' bash "$script" >"$OMARCHY_INSTALL_LOG_FILE" 2>&1 - else - bash -eE -c 'source "$1"' bash "$script" >"$OMARCHY_INSTALL_LOG_FILE" 2>&1 - fi + PS4='+ ${BASH_SOURCE[0]##*/}:${LINENO}:${FUNCNAME[0]:-main}: ' \ + "${runner[@]}" -c 'source "$1"' bash "$script" >"$OMARCHY_INSTALL_LOG_FILE" 2>&1 fi exit_code=$? diff --git a/install/login/system.sh b/install/login/all.sh similarity index 100% rename from install/login/system.sh rename to install/login/all.sh diff --git a/install/omarchy-other.packages b/install/omarchy-other.packages index e9df35d0..9cc298ba 100644 --- a/install/omarchy-other.packages +++ b/install/omarchy-other.packages @@ -62,6 +62,9 @@ linux-firmware-marvell # Dell laptop support packages dell-xps-touchpad-haptics +# Speaker tunings (LV2 limiter every tuning ends in) +lsp-plugins-lv2 + # T2 MacBook support packages apple-bcm-firmware apple-t2-audio-config diff --git a/install/post-install/system.sh b/install/post-install/all.sh similarity index 100% rename from install/post-install/system.sh rename to install/post-install/all.sh diff --git a/install/user/first-run/audio-tuning.sh b/install/user/first-run/audio-tuning.sh new file mode 100755 index 00000000..1564efbe --- /dev/null +++ b/install/user/first-run/audio-tuning.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +# Apply the speaker tuning for this laptop. Runs at first-run rather than at +# finalize-user time because finalize-user also runs in the ISO chroot, where +# there is no audio server: the sink the tuning has to target does not exist, so +# nothing could be written and nothing would retry -- the finalizer marks all +# shipped migrations complete on a fresh install. By first-run the session is up +# and the sink is present. +# +# A no-op on machines no tuning matches. + +set -euo pipefail + +omarchy-audio-tuning on diff --git a/install/user/first-run/enable-user-units.sh b/install/user/first-run/enable-user-units.sh index efc8123c..a5d7cb8e 100755 --- a/install/user/first-run/enable-user-units.sh +++ b/install/user/first-run/enable-user-units.sh @@ -16,5 +16,4 @@ systemctl --user enable --now \ bt-agent.service \ omarchy-recover-internal-monitor.service \ omarchy-sleep-lock.service \ - omarchy-update-user-notify.path \ - omarchy-update-user-notify.service + omarchy-migrate-notify.service diff --git a/install/user/first-run/install-voxtype.hook b/install/user/first-run/install-voxtype.hook index 63d025a0..1fcb0cbc 100644 --- a/install/user/first-run/install-voxtype.hook +++ b/install/user/first-run/install-voxtype.hook @@ -4,7 +4,16 @@ set -e show_invitation() { if [[ -n $(omarchy-notification-send -u critical -g  "Install Dictation with Voxtype" "Click to install voice dictation for Omarchy." -a) ]]; then - omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install + # Launch the installer in its own transient unit so this invitation service + # can exit right after the click. If it stayed alive for the life of the + # install terminal, the installer's omarchy-restart-shell would re-trigger + # this still-running *-invitation unit and pop the toast a second time. + # KillMode=process because the launcher's setsid forks and returns, so the + # unit's main process exits within milliseconds. The default control-group + # kill would take the terminal down with it before it ever appears. + systemd-run --user --collect --quiet -p KillMode=process \ + --unit=omarchy-voxtype-install \ + omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install fi } diff --git a/install/user/first-run/setup-fingerprint.hook b/install/user/first-run/setup-fingerprint.hook new file mode 100644 index 00000000..b863a92d --- /dev/null +++ b/install/user/first-run/setup-fingerprint.hook @@ -0,0 +1,30 @@ +#!/bin/bash + +set -e + +show_invitation() { + if [[ -n $(omarchy-notification-send -u critical -g 󰈷 "Setup Fingerprint Reader" "Enable sudo and unlocking with your fingerprint." -a) ]]; then + # Launch the setup in its own transient unit so this invitation service can + # exit right after the click. If it stayed alive for the life of the setup + # terminal, the setup's omarchy-restart-shell would re-trigger this + # still-running *-invitation unit and pop the toast a second time. + # KillMode=process because the launcher's setsid forks and returns, so the + # unit's main process exits within milliseconds. The default control-group + # kill would take the terminal down with it before it ever appears. + systemd-run --user --collect --quiet -p KillMode=process \ + --unit=omarchy-setup-security-fingerprint \ + omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fingerprint + fi +} + +if [[ ${1:-} == "--show" ]]; then + show_invitation +# Only invite when there's a reader to use and it isn't set up yet (the lock +# PAM file is the last thing the setup writes on success). +elif omarchy-hw-fingerprint && [[ ! -f /etc/pam.d/omarchy-lock-fingerprint ]] && + omarchy-done ensure fingerprint-setup-invitation; then + # Keep the notification action alive after the update terminal closes. + systemd-run --user --collect --quiet --service-type=exec \ + --unit=omarchy-fingerprint-setup-invitation \ + bash "$0" --show +fi diff --git a/install/user/first-run/wifi.sh b/install/user/first-run/wifi.sh index 82d95667..16a927f4 100644 --- a/install/user/first-run/wifi.sh +++ b/install/user/first-run/wifi.sh @@ -16,6 +16,10 @@ notify_wifi() { if ! ping -c3 -W1 1.1.1.1 >/dev/null 2>&1; then notify_update "When you have internet, click to update the system." + # Both toasts are sent from background subshells, so let the update one + # register before queueing Wi-Fi. Newest stacks on top, and Wi-Fi is what + # you need first. + sleep 0.3 notify_wifi else notify_update "Click to update the system." diff --git a/migrations/1784818437.sh b/migrations/1784818437.sh new file mode 100644 index 00000000..89cec5b7 --- /dev/null +++ b/migrations/1784818437.sh @@ -0,0 +1,23 @@ +echo "Gate sudo and polkit fingerprint auth behind the lid state (password when the lid is shut)" + +# Existing fingerprint setups have pam_fprintd first in /etc/pam.d/sudo and +# /etc/pam.d/polkit-1 but no lid gate, so a closed-lid sudo or pkexec would +# block on the unreachable reader for the full pam_fprintd timeout before +# offering the password. Insert a pam_exec gate before pam_fprintd that skips +# fingerprint while the lid is closed. New setups already get this from +# omarchy-setup-security-fingerprint. +# +# The gate points at the fixed /usr/bin path the omarchy package always +# provides, so it keeps working across package installs and dev-link (which +# overlays $OMARCHY_PATH but leaves /usr/bin untouched). pam_exec needs a +# literal absolute path — it does not expand env vars. + +gate="auth [success=1 default=ignore] pam_exec.so quiet /usr/bin/omarchy-hw-laptop-closed" + +for pam in /etc/pam.d/sudo /etc/pam.d/polkit-1; do + if [[ -f $pam ]] && + grep -q 'pam_fprintd\.so' "$pam" && + ! grep -q 'omarchy-hw-laptop-closed' "$pam"; then + sudo sed -i "/pam_fprintd\.so/i $gate" "$pam" + fi +done diff --git a/migrations/1784849592.sh b/migrations/1784849592.sh new file mode 100644 index 00000000..3ca290c5 --- /dev/null +++ b/migrations/1784849592.sh @@ -0,0 +1,17 @@ +echo "Add tmux hooks that surface waiting windows in the bar" + +tmux_config="$HOME/.config/tmux/tmux.conf" + +if [[ -f $tmux_config ]] && ! grep -q 'alert-bell' "$tmux_config"; then + cat >>"$tmux_config" <<'EOF' + +# Alerts +set-hook -g alert-bell 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' +set-hook -g alert-activity 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' +set-hook -g alert-silence 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' +set-hook -g after-select-window 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' +set-hook -g client-session-changed 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' +EOF + + omarchy-restart-tmux +fi diff --git a/migrations/1784909971.sh b/migrations/1784909971.sh new file mode 100644 index 00000000..4f5aafdc --- /dev/null +++ b/migrations/1784909971.sh @@ -0,0 +1,12 @@ +echo "Regenerate mise wrappers to stop them recursing through PATH" + +for wrapper in "$HOME/.local/bin"/*; do + [[ -f $wrapper && -x $wrapper ]] || continue + + package=$(sed -n 's/^mise use -g "\(.*\)"$/\1/p' "$wrapper") + bin=$(sed -n 's/^exec "\(.*\)" "\$@"$/\1/p' "$wrapper") + + if [[ -n $package && -n $bin ]]; then + omarchy-mise-install "$package" "$(basename "$wrapper")" "$bin" + fi +done diff --git a/migrations/1784914435.sh b/migrations/1784914435.sh new file mode 100644 index 00000000..a7899c4e --- /dev/null +++ b/migrations/1784914435.sh @@ -0,0 +1,19 @@ +echo "Keep Wi-Fi power save off for lower latency" + +as_root() { + if (( EUID == 0 )); then + "$@" + else + sudo "$@" + fi +} + +as_root nmcli general reload conf >/dev/null 2>&1 || true + +# NetworkManager only applies wifi.powersave when a connection activates, so +# also switch it off directly for the running session. +shopt -s nullglob +for wireless in /sys/class/net/*/wireless; do + iface=$(basename "$(dirname "$wireless")") + as_root iw dev "$iface" set power_save off 2>/dev/null || true +done diff --git a/migrations/1784917531.sh b/migrations/1784917531.sh new file mode 100644 index 00000000..f32a3b7d --- /dev/null +++ b/migrations/1784917531.sh @@ -0,0 +1,17 @@ +echo "Unpack the initramfs synchronously so Plymouth survives early boot" + +# Kernel 7.1 unpacks the initramfs asynchronously, which races /init: the +# early /proc, /sys, /dev, and /run mounts fail while unpacking settles, so +# plymouthd exits when it can't read /proc/cmdline and encrypted boots fall +# back to an unthemed text LUKS prompt. Force synchronous unpacking until the +# race is fixed upstream. The packaged omarchy-defaults.conf now carries the +# same parameter for fresh installs. + +if omarchy-cmd-present limine-mkinitcpio && + [[ -f /etc/limine-entry-tool.d/omarchy-defaults.conf ]] && + ! grep -rqs "initramfs_async" /etc/limine-entry-tool.d/ /etc/default/limine; then + echo 'KERNEL_CMDLINE[default]+=" initramfs_async=0"' | + sudo tee -a /etc/limine-entry-tool.d/omarchy-defaults.conf >/dev/null + + sudo limine-mkinitcpio +fi diff --git a/migrations/1784955584.sh b/migrations/1784955584.sh new file mode 100644 index 00000000..b57d9ddc --- /dev/null +++ b/migrations/1784955584.sh @@ -0,0 +1,17 @@ +echo "Track tmux output while its terminal is unfocused" + +tmux_config="$HOME/.config/tmux/tmux.conf" + +if [[ -f $tmux_config ]] && ! grep -q 'client-focus-out\[100\].*omarchy-tmux-alert track' "$tmux_config"; then + sed -i \ + -e 's|^set-hook -g after-select-window .*|set-hook -g after-select-window '"'"'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"'"'"'|' \ + -e 's|^set-hook -g client-session-changed .*|set-hook -g client-session-changed '"'"'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"'"'"'|' \ + "$tmux_config" + + cat >>"$tmux_config" <<'EOF' +set-hook -g client-focus-out[100] 'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"' +set-hook -g client-focus-in[100] 'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"' +EOF + + omarchy-restart-tmux +fi diff --git a/migrations/1784960000.sh b/migrations/1784960000.sh new file mode 100644 index 00000000..090c7aa7 --- /dev/null +++ b/migrations/1784960000.sh @@ -0,0 +1,10 @@ +echo "Install the speaker tuning for XPS 2026 14/16" + +# Speaker tunings are PipeWire filter-chain drop-ins gated on a hardware +# predicate, so this is a no-op on machines without one. The limiter is an LV2 +# plugin and the graph will not instantiate without it. + +if omarchy-audio-tuning match >/dev/null 2>&1; then + omarchy-pkg-add lsp-plugins-lv2 + omarchy-audio-tuning on +fi diff --git a/migrations/1784961000.sh b/migrations/1784961000.sh new file mode 100644 index 00000000..75b631da --- /dev/null +++ b/migrations/1784961000.sh @@ -0,0 +1,24 @@ +echo "Tune reclaim for swap on zram" + +# Everything here only applies the shipped config early; boot picks it up +# regardless. Nothing is worth failing the migration chain over, so each step +# falls back to asking for a reboot. + +# Load our file specifically rather than --system, which returns nonzero for +# any invalid key in any admin sysctl file on the machine. +sudo sysctl -p /etc/sysctl.d/99-omarchy-sysctl.conf >/dev/null || true + +if sudo systemctl daemon-reload; then + # Resizing swaps the device off first, which faults every stored page back + # into memory. That's only cheap while it's empty, so a device under + # pressure keeps its old size until the next boot. A device that doesn't + # exist yet reads as empty, which is what we want: the restart brings it up + # against the unit daemon-reload just generated. + zram_used=$(awk '$1 == "/dev/zram0" {print $4}' /proc/swaps) + + if [[ ${zram_used:-0} == 0 ]] && sudo systemctl restart dev-zram0.swap; then + exit 0 + fi +fi + +omarchy-state set reboot-required diff --git a/migrations/1784970000.sh b/migrations/1784970000.sh new file mode 100644 index 00000000..e01509e5 --- /dev/null +++ b/migrations/1784970000.sh @@ -0,0 +1,25 @@ +echo "Give the pre-suspend lock a window it can actually finish in" + +# logind's five second default expires while Quickshell is still securing the +# session on lid close, and it suspends regardless. The shipped drop-in raises +# InhibitDelayMaxSec, but logind only reads it on reload. +# +# Reload rather than restart: restarting systemd-logind tears down the session. +sudo systemctl reload systemd-logind >/dev/null 2>&1 || true + +# Check the property logind actually enforces, not the reload's exit status: a +# reload that returns success while the drop-in is missing or unparsed leaves +# the old five second window in place. omarchy-system-sleep-lock reads this same +# property at runtime, so it stays correct either way -- the reboot flag is only +# about getting the wider window to take effect. +# +# Both reads have to survive failing: a missing drop-in or an unreachable logind +# is the very condition being tested for, and migrations run under -e. +dropin=/etc/systemd/logind.conf.d/20-inhibit-delay.conf +expected_s=$(sed -n 's/^InhibitDelayMaxSec=//p' "$dropin" 2>/dev/null || true) +effective_us=$(busctl get-property org.freedesktop.login1 /org/freedesktop/login1 \ + org.freedesktop.login1.Manager InhibitDelayMaxUSec 2>/dev/null | awk '{print $2}' || true) + +if [[ -z $expected_s || $effective_us != $((expected_s * 1000000)) ]]; then + omarchy-state set reboot-required +fi diff --git a/migrations/1784989000.sh b/migrations/1784989000.sh new file mode 100644 index 00000000..3f806b6f --- /dev/null +++ b/migrations/1784989000.sh @@ -0,0 +1,41 @@ +echo "Move the bar indicators to the left of the clock" + +config_file="$HOME/.config/omarchy/shell.json" + +if [[ -s $config_file ]]; then + tmp=$(mktemp) + jq ' + def entry_id: + if type == "string" then + . + elif type == "object" then + (.id // "") + else + "" + end; + + def entry_index($id): + [range(0; length) as $i | select((.[$i] | entry_id) == $id) | $i][0]; + + def place_indicators_before_clock: + if type != "array" then + . + else + (entry_index("omarchy.clock")) as $clock_index | + (entry_index("omarchy.indicators")) as $indicators_index | + if $clock_index == null or $indicators_index == null or $indicators_index < $clock_index then + . + else + . as $entries | + ($entries[$indicators_index]) as $indicators_entry | + ($entries | del(.[$indicators_index])) as $without_indicators | + ($without_indicators | entry_index("omarchy.clock")) as $new_clock_index | + $without_indicators[0:$new_clock_index] + [$indicators_entry] + $without_indicators[$new_clock_index:] + end + end; + + .bar.layout.center |= place_indicators_before_clock + ' "$config_file" >"$tmp" && mv "$tmp" "$config_file" || rm -f "$tmp" +fi + +omarchy-restart-shell diff --git a/migrations/1785002349.sh b/migrations/1785002349.sh new file mode 100644 index 00000000..3b3bb998 --- /dev/null +++ b/migrations/1785002349.sh @@ -0,0 +1,22 @@ +echo "Repair Neovim theme symlinks the earlier relink missed" + +theme_link="$HOME/.config/nvim/lua/plugins/theme.lua" +current_absolute_target="$HOME/.local/state/omarchy/current/theme/neovim.lua" +current_relative_target="../../../../.local/state/omarchy/current/theme/neovim.lua" + +[[ -L $theme_link ]] || exit 0 + +target=$(readlink "$theme_link") || exit 0 + +# Already pointing at the state directory. +[[ $target == "$current_relative_target" || $target == "$current_absolute_target" ]] && exit 0 + +# 1781158082.sh matched an explicit list of legacy spellings and missed at least +# one ("../../../../.config/omarchy/current/theme/neovim.lua"), leaving those +# links dangling. Match the shared suffix instead so every spelling is repaired. +case "$target" in + */omarchy/current/theme/neovim.lua) ;; + *) exit 0 ;; +esac + +ln -sfn "$current_relative_target" "$theme_link" diff --git a/migrations/1785013000.sh b/migrations/1785013000.sh new file mode 100644 index 00000000..b6a07978 --- /dev/null +++ b/migrations/1785013000.sh @@ -0,0 +1,36 @@ +echo "Move zram tuning to a vendor drop-in" + +zram_conf="${OMARCHY_ZRAM_CONF:-/etc/systemd/zram-generator.conf}" +zram_dropin="${OMARCHY_ZRAM_DROPIN:-/usr/lib/systemd/zram-generator.conf.d/90-omarchy.conf}" + +# The tuning ships as /usr/lib/systemd/zram-generator.conf.d/90-omarchy.conf. +# Drop-ins outrank the main config file, so a leftover /etc copy decides nothing +# and only implies /etc is where zram gets configured. + +[[ -f $zram_conf ]] || exit 0 + +# Only once the replacement is on disk. zram-generator makes no device at all +# when nothing configures one, so until the drop-in lands the /etc copy is the +# only thing standing between this machine and no zram swap. The update pipeline +# installs packages before it runs migrations, but a dev checkout carries +# migrations from a release the installed package does not have yet. +[[ -f $zram_dropin ]] || exit 0 + +# Package-owned copies go away with their package on upgrade. +pacman -Qo "$zram_conf" &>/dev/null && exit 0 + +# archinstall writes exactly a [zram0] section with one compression-algorithm +# line. Anything else is a deliberate local override. grep exits 1 on a config +# that sets nothing at all, which omarchy-migrate's -e would take as a failed +# migration and block every migration behind this one. +settings=$(grep -vE '^[[:space:]]*([#;]|$)' "$zram_conf" | tr -d '[:space:]') || true + +if [[ -z $settings || $settings =~ ^\[zram0\]compression-algorithm=[[:alnum:]-]+$ ]]; then + # A refused sudo just leaves the file for next time; tidying is not worth + # failing the migration chain over. + sudo rm -f "$zram_conf" || true +else + echo "Keeping $zram_conf; it has local edits." + echo "Omarchy's drop-in overrides it. Move your changes to" + echo "/etc/systemd/zram-generator.conf.d/99-local.conf to keep them in effect." +fi diff --git a/migrations/1785090473.sh b/migrations/1785090473.sh new file mode 100644 index 00000000..1e64b6ee --- /dev/null +++ b/migrations/1785090473.sh @@ -0,0 +1,17 @@ +echo "Switch fingerprint support back to stock libfprint" + +# libfprint-git existed to carry the focaltech_moc driver and the FocalTech +# FT9349 device ID (2808:a97a) before any release shipped them. libfprint +# 1.94.100 has both, so fingerprint setups go back to the stock Arch package. + +# The remove/install pair below isn't one transaction: if the install failed +# on a previous run, libfprint-git is already gone but fprintd is left with +# no libfprint — the elif finishes the job on rerun. +if pacman -Q libfprint-git &>/dev/null; then + # Deps-only removal keeps fprintd installed while its libfprint + # dependency is swapped out underneath it. + sudo pacman -Rdd --noconfirm libfprint-git + omarchy-pkg-add libfprint +elif pacman -Q fprintd &>/dev/null && ! pacman -Q libfprint &>/dev/null; then + omarchy-pkg-add libfprint +fi diff --git a/migrations/1785094500.sh b/migrations/1785094500.sh new file mode 100644 index 00000000..d858ab63 --- /dev/null +++ b/migrations/1785094500.sh @@ -0,0 +1,38 @@ +echo "Resize zram to match the shipped config" + +# 90-omarchy.conf changed the device size. The migration that first applied the +# zram tuning is one-shot, so machines that already ran it have the new file on +# disk and the old device still running; nothing would pick the size up until +# something else rebooted them. + +zram_disksize=${OMARCHY_ZRAM_DISKSIZE:-/sys/block/zram0/disksize} +swaps=${OMARCHY_SWAPS:-/proc/swaps} + +# The size is an expression, and the generator is the only thing that evaluates +# it. Ask it what the shipped config comes to rather than repeating the +# arithmetic here, where the two would drift apart on the next tuning change. +units=$(mktemp -d) +trap 'rm -rf "$units"' EXIT +desired_mb=$(/usr/lib/systemd/system-generators/zram-generator "$units" 2>&1 | + grep -oP '/dev/zram0 with \K[0-9]+') || true + +actual_bytes=$(cat "$zram_disksize" 2>/dev/null) || actual_bytes=0 + +# A device already at the right size needs neither a restart nor a reboot, +# whether an earlier boot picked the config up or another user got here first. +if [[ -n $desired_mb && $actual_bytes == $((desired_mb * 1024 * 1024)) ]]; then + exit 0 +fi + +if sudo systemctl daemon-reload; then + # Resizing swaps the device off first, which faults every stored page back + # into memory. That's only cheap while it's empty, so a device under + # pressure keeps its old size until the next boot. + zram_used=$(awk '$1 == "/dev/zram0" {print $4}' "$swaps") + + if [[ ${zram_used:-0} == 0 ]] && sudo systemctl restart dev-zram0.swap; then + exit 0 + fi +fi + +omarchy-state set reboot-required diff --git a/migrations/1785095882.sh b/migrations/1785095882.sh new file mode 100644 index 00000000..cb633bac --- /dev/null +++ b/migrations/1785095882.sh @@ -0,0 +1,39 @@ +echo "Only check for pending migrations at login, not on every package update" + +# omarchy-update-user-notify.path watched /usr/share/omarchy/migrations, but +# pacman writes that directory during every update -- including the blessed +# `omarchy update`, which runs omarchy-migrate a step later. The watcher fired a +# critical notification for migrations that were already being applied in the +# visible update terminal. Retire the watcher and keep only the once-per-login +# check, now named after the command it runs. + +wants_dir="$HOME/.config/systemd/user/graphical-session.target.wants" + +systemctl --user daemon-reload >/dev/null 2>&1 || true + +# The watcher's unit file is already gone, but it stays loaded in a session that +# started before this update, so stop it before it can fire again. +systemctl --user stop omarchy-update-user-notify.path >/dev/null 2>&1 || true + +# Enable the replacement before dropping the old enablement, so a failure here +# can never leave a user with no notifier at all. Enable without --now: this +# usually runs from inside `omarchy update`, and starting the notifier here would +# pop a toast for the migrations running right after it -- the exact behavior +# being removed. `systemctl enable` also needs a live user manager, which +# `omarchy update` over SSH does not have, so fall back to writing precisely the +# symlink it would have written rather than silently doing nothing. +if ! systemctl --user enable omarchy-migrate-notify.service >/dev/null 2>&1; then + mkdir -p "$wants_dir" + ln -sfn /usr/lib/systemd/user/omarchy-migrate-notify.service \ + "$wants_dir/omarchy-migrate-notify.service" +fi + +# Drop the retired enablement by hand instead of through `systemctl disable`. +# The package ships omarchy-update-user-notify.service as a compatibility +# symlink onto the new unit, for users who have not reached this migration yet, +# so disabling that name here would disable the replacement along with it. +rm -f "$wants_dir/omarchy-update-user-notify.path" \ + "$wants_dir/omarchy-update-user-notify.service" + +systemctl --user reset-failed omarchy-update-user-notify.path >/dev/null 2>&1 || true +systemctl --user daemon-reload >/dev/null 2>&1 || true diff --git a/shell/Commons/Color.qml b/shell/Commons/Color.qml index 466a9158..54163a40 100644 --- a/shell/Commons/Color.qml +++ b/shell/Commons/Color.qml @@ -91,15 +91,6 @@ QtObject { property color border: root.composed("notifications.border", "notifications.border-alpha", root.accent, 1.0) property color countdown: root.pick("notifications.countdown", root.accent) } - readonly property QtObject launcher: QtObject { - property color background: root.composed("launcher.background", "launcher.background-alpha", root.background, 1.0) - property color text: root.pick("launcher.text", root.foreground) - property color border: root.composed("launcher.border", "launcher.border-alpha", root.foreground, 1.0) - property color scrim: root.composed("launcher.scrim", "launcher.scrim-alpha", root.background, 0.5) - property color selectedBackground: root.composed("launcher.selected-background", "launcher.selected-background-alpha", root.foreground, 0.08) - property color selectedText: root.pick("launcher.selected-text", root.accent) - property color selectedBorder: root.composed("launcher.selected-border", "launcher.selected-border-alpha", root.foreground, 0.0) - } readonly property QtObject menu: QtObject { property color background: root.composed("menu.background", "menu.background-alpha", root.background, 1.0) property color text: root.pick("menu.text", root.foreground) diff --git a/shell/Commons/Style.qml b/shell/Commons/Style.qml index 32a78f49..ec61252b 100644 --- a/shell/Commons/Style.qml +++ b/shell/Commons/Style.qml @@ -309,7 +309,7 @@ QtObject { return fallback } - // The launcher, menu, polkit, emojis, and clipboard surfaces honor an + // The menu, polkit, emojis, and clipboard surfaces honor an // OMARCHY_MENU_FONT override for users who want a different family on the // summoned popups than on the bar. Resolved once at startup; an empty env // value falls back to the shared fontconfig alias. diff --git a/shell/README.md b/shell/README.md index 704bd71c..6a5f32a0 100644 --- a/shell/README.md +++ b/shell/README.md @@ -23,7 +23,6 @@ shell/ BarWidgetRegistry.qml unified registry for bar widgets (1p + 3p) plugins/ bar/ first-party plugins (see plugins/README.md) - launcher/ image-picker/ menu/ notifications/ diff --git a/shell/Ui/BarWidget.qml b/shell/Ui/BarWidget.qml index 10e9c324..a0c9584d 100644 --- a/shell/Ui/BarWidget.qml +++ b/shell/Ui/BarWidget.qml @@ -22,6 +22,18 @@ Item { readonly property bool vertical: bar ? bar.vertical : false readonly property int barSize: bar ? bar.barSize : Style.bar.sizeHorizontal + // Run `method` on every live instance of this widget. An IPC target only + // ever routes to one handler, but a bar surface exists per monitor, so the + // instance that owns the target relays the call to its peers — otherwise a + // refresh would land on a single screen and leave the others stale. + function broadcast(method) { + var items = bar && typeof bar.moduleWidgets === "function" + ? bar.moduleWidgets(moduleName) : [root] + for (var i = 0; i < items.length; i++) { + if (items[i] && typeof items[i][method] === "function") items[i][method]() + } + } + // Read a single value from this widget's inline shell.json entry, with a // fallback for missing/null values. Every widget that takes user-tunable // settings needs this; defining it once on the base keeps the wiring diff --git a/shell/Ui/ConfirmDialog.qml b/shell/Ui/ConfirmDialog.qml index 45d8da18..ed4f8c98 100644 --- a/shell/Ui/ConfirmDialog.qml +++ b/shell/Ui/ConfirmDialog.qml @@ -48,8 +48,10 @@ Item { BorderSurface { id: card - width: Math.min(parent.width - Style.space(96), Style.space(370)) - height: Style.space(132) + width: Math.min(parent.width - Style.space(32), Style.space(370)) + // Grows with the wrapped message so narrow hosts (like the menu card) + // don't squeeze the text into the buttons. + height: card.contentTopInset + card.contentBottomInset + messageText.implicitHeight + Style.space(20) + Style.space(34) anchors.centerIn: parent color: root.background borderSpec: Border.flat(root.selectedText, Style.normalBorderWidth) @@ -66,6 +68,7 @@ Item { anchors.leftMargin: card.contentLeftInset Text { + id: messageText anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top diff --git a/shell/plugins/README.md b/shell/plugins/README.md index 479148db..ab89b02e 100644 --- a/shell/plugins/README.md +++ b/shell/plugins/README.md @@ -14,13 +14,12 @@ User-installed plugins live alongside these conceptually but on disk under | Plugin | id | kinds | entry point | |---------------|---------------------------|-------------------------|---------------------------------------| | Bar | `omarchy.bar` | `bar` | `bar/Bar.qml` | -| Launcher | `omarchy.launcher` | `overlay` | `launcher/Launcher.qml` | | Image picker | `omarchy.image-picker` | `overlay` | `image-picker/ImagePicker.qml` | | Emojis | `omarchy.emojis` | `overlay` | `emojis/Emojis.qml` | | Clipboard mgr | `omarchy.clipboard` | `overlay` | `clipboard/Clipboard.qml` | | Reminders | `omarchy.reminders` | `overlay` | `reminders/ReminderFlow.qml` | | Omarchy menu | `omarchy.menu` | `menu`, `bar-widget` | `menu/Menu.qml`, `menu/BarWidget.qml` | -| Notifications | `omarchy.notifications` | `service`, `bar-widget` | `notifications/Service.qml`, `notifications/BarWidget.qml` | +| Notifications | `omarchy.notifications` | `service` | `notifications/Service.qml` | | Audio | `omarchy.audio` | `bar-widget` | `panels/audio/Panel.qml` | | Bluetooth | `omarchy.bluetooth` | `bar-widget` | `panels/bluetooth/Panel.qml` | | Monitor | `omarchy.monitor` | `bar-widget` | `panels/monitor/Panel.qml` | @@ -49,14 +48,6 @@ providing [`config/omarchy/shell.json`](../../config/omarchy/shell.json) when the user has no file). See [`bar/README.md`](bar/README.md) for the widget catalogue and customization schema. -## Launcher - -Quickshell-powered launcher. It uses Quickshell's native -`DesktopEntries` model for discovery/activation and renders inside the -long-running shell with the legacy launcher card dimensions, colors, row -spacing, icon sizing, and keyboard behavior. Summoned directly over shell IPC -by the `SUPER + SPACE` binding and the Omarchy menu Apps row. - ## Image picker Fullscreen image-grid selector overlay. Used by `omarchy-menu-images` diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index a7a9c4be..af40f78b 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -384,11 +384,25 @@ Item { return true } + // Every live instance of a widget id. A bar surface is built per monitor, so + // a widget that appears once in the layout is still live once per screen. + function moduleWidgets(pluginId) { + var id = String(pluginId || "") + var items = [] + if (!id) return items + for (var i = 0; i < moduleSlots.length; i++) { + var slot = moduleSlots[i] + if (!slot || !slot.activeItem || slot.moduleName !== id) continue + items.push(slot.activeItem) + } + return items + } + // Resolve the live bar-widget instance for a plugin id (e.g. "omarchy.bluetooth"). // Only widgets that expose popup open/close methods count; plain indicators // (clock, workspaces, tray) return null. Used by shell.summon/toggle so - // panel hotkeys route through the bar instead of a per-target IpcHandler - // that goes stale when the bar reloads its widget instances. + // panel hotkeys route through the bar instead of a per-target IPC handler + // that only reaches whichever per-monitor instance claimed the target. function findPanelWidget(pluginId) { var id = String(pluginId || "") if (!id) return null diff --git a/shell/plugins/bar/README.md b/shell/plugins/bar/README.md index 3606e9ab..a1d93cc6 100644 --- a/shell/plugins/bar/README.md +++ b/shell/plugins/bar/README.md @@ -59,7 +59,6 @@ Example `shell.json` (bar subtree only shown): | `omarchy.clock` | Date/time label | left = alternate format · right = timezone selector | | `omarchy.media` | MPRIS now-playing — scrolling track + artist, cover-art popup | left = play/pause · middle = next · scroll = prev/next · right = popup | | `omarchy.indicators` | Manual state indicators | left = indicator action | -| `omarchy.notifications` | Bell with badge + popup with recent notifications, DND toggle | left = popup · right = toggle DND | | `omarchy.system-update` | Available update indicator | left = update | | `omarchy.tray` | System tray | hover = reveal drawer · right on chevron = manage | | `omarchy.weather` | Weather icon + popup with forecast | left = popup · right = full notification | @@ -169,8 +168,8 @@ Widgets receive `bar` (the shell root), `moduleName` (string), and `settings` (o First-party bar widgets are manifest-backed just like third-party widgets. Simple widgets carry sibling manifests such as `widgets/Clock.manifest.json`; richer popup plugins live in feature directories such as `../panels/audio/`, -`../panels/network/`, and `../model-usage/`; and feature plugins such as `omarchy.menu`, `omarchy.media`, and -`omarchy.notifications` declare their bar-widget entry points in their own +`../panels/network/`, and `../model-usage/`; and feature plugins such as +`omarchy.menu` and `omarchy.media` declare their bar-widget entry points in their own `manifest.json`. Bar layout ids are namespaced, e.g. `omarchy.audio`, `omarchy.network`, and `omarchy.clock`. Older UpperCamelCase ids such as `AudioPanel` and `Clock` are migrated forward; new configs should use the diff --git a/shell/plugins/bar/indicators/TmuxAlert.qml b/shell/plugins/bar/indicators/TmuxAlert.qml new file mode 100644 index 00000000..8ceb38c6 --- /dev/null +++ b/shell/plugins/bar/indicators/TmuxAlert.qml @@ -0,0 +1,46 @@ +import QtQuick +import Quickshell +import qs.Ui + +BarIndicator { + id: root + + readonly property var tmuxService: bar?.shell?.firstPartyServiceFor("omarchy.tmux") + + active: tmuxService ? tmuxService.waiting : false + activeText: "󰆍" + inactiveText: "󰆍" + activeTooltipText: tmuxService ? tmuxService.tooltip : "" + inactiveTooltipText: "No Terminal Waiting" + + // The service owns the probe and its polling; the tmux hooks still reach it + // through the indicator host's refresh broadcast, which coalesces into a + // single run no matter how many bars relay it. + function refresh() { + if (root.tmuxService) root.tmuxService.refresh() + } + + SequentialAnimation { + running: root.active + loops: Animation.Infinite + + PauseAnimation { duration: 2740 } + NumberAnimation { target: root; property: "textRotation"; to: -10; duration: 50; easing.type: Easing.OutQuad } + NumberAnimation { target: root; property: "textRotation"; to: 10; duration: 70; easing.type: Easing.InOutQuad } + NumberAnimation { target: root; property: "textRotation"; to: -7; duration: 55; easing.type: Easing.InOutQuad } + NumberAnimation { target: root; property: "textRotation"; to: 7; duration: 45; easing.type: Easing.InOutQuad } + NumberAnimation { target: root; property: "textRotation"; to: 0; duration: 40; easing.type: Easing.OutQuad } + + onStopped: root.textRotation = 0 + } + + Connections { + target: root.indicatorHost + ignoreUnknownSignals: true + function onRefreshRequested() { root.refresh() } + } + + onPressed: function() { + Quickshell.execDetached(["omarchy-tmux-alert", "focus"]) + } +} diff --git a/shell/plugins/bar/widgets/Clock.qml b/shell/plugins/bar/widgets/Clock.qml index 2b466ed6..60eb6749 100644 --- a/shell/plugins/bar/widgets/Clock.qml +++ b/shell/plugins/bar/widgets/Clock.qml @@ -49,7 +49,7 @@ BarWidget { IpcHandler { target: "omarchy.clock" - function refresh(): void { root.refresh() } + function refresh(): void { root.broadcast("refresh") } } WidgetButton { diff --git a/shell/plugins/bar/widgets/Indicators.manifest.json b/shell/plugins/bar/widgets/Indicators.manifest.json index 858e758d..2a52f979 100644 --- a/shell/plugins/bar/widgets/Indicators.manifest.json +++ b/shell/plugins/bar/widgets/Indicators.manifest.json @@ -27,9 +27,19 @@ "emptyText": "No indicators", "options": [ { - "value": "Dnd", - "label": "Do not disturb", - "description": "Notification silencing" + "value": "TmuxAlert", + "label": "Tmux alert", + "description": "Tmux windows waiting for attention" + }, + { + "value": "Dictation", + "label": "Dictation", + "description": "Voice typing status" + }, + { + "value": "ScreenRecording", + "label": "Screen recording", + "description": "GPU screen recorder status" }, { "value": "Reminder", @@ -41,20 +51,15 @@ "label": "Night light", "description": "Blue-light filter" }, + { + "value": "Dnd", + "label": "Do not disturb", + "description": "Notification silencing" + }, { "value": "StayAwake", "label": "Stay awake", "description": "Idle lock and screensaver override" - }, - { - "value": "ScreenRecording", - "label": "Screen recording", - "description": "GPU screen recorder status" - }, - { - "value": "Dictation", - "label": "Dictation", - "description": "Voice typing status" } ] }, diff --git a/shell/plugins/bar/widgets/Indicators.qml b/shell/plugins/bar/widgets/Indicators.qml index 5b3a1f3a..d0f6d1a2 100644 --- a/shell/plugins/bar/widgets/Indicators.qml +++ b/shell/plugins/bar/widgets/Indicators.qml @@ -8,7 +8,7 @@ BarWidget { id: root moduleName: "omarchy.indicators" - readonly property var defaultIndicatorEntries: [ "Dnd", "Reminder", "NightLight", "StayAwake", "ScreenRecording", "Dictation" ] + readonly property var defaultIndicatorEntries: [ "TmuxAlert", "Dictation", "ScreenRecording", "Reminder", "NightLight", "Dnd", "StayAwake" ] readonly property var indicatorEntries: indicatorEntriesFromSettings(settings) property var activeIndicatorIds: [] property var indicatorActiveStates: ({}) @@ -142,7 +142,9 @@ BarWidget { indicatorActiveStates = states var ids = orderedActiveIds(states, activeIndicatorIds) - if (active && ids.indexOf(id) === -1 && hasIndicatorId(id)) ids.push(id) + // The active block sits closest to the clock, so newcomers go on the far + // side of it. Appending would shove everything already showing sideways. + if (active && ids.indexOf(id) === -1 && hasIndicatorId(id)) ids.unshift(id) activeIndicatorIds = ids syncActiveIndicatorModel() } @@ -152,6 +154,8 @@ BarWidget { syncActiveIndicatorModel() } + function refresh() { root.refreshRequested() } + onIndicatorEntriesChanged: syncActiveIndicatorOrder() implicitWidth: root.vertical @@ -165,7 +169,7 @@ BarWidget { target: "omarchy.indicators" function refresh(): void { - root.refreshRequested() + root.broadcast("refresh") } } @@ -190,14 +194,6 @@ BarWidget { onHoveredChanged: root.setIndicatorAreaHovered(hovered) } - ActiveIndicatorBlock { - id: activeHorizontalBlock - indicatorsModule: root - indicatorModel: activeIndicatorModel - horizontal: true - reportActiveState: !root.vertical - } - Item { id: inactiveHorizontalArea @@ -221,6 +217,14 @@ BarWidget { onHoveredChanged: root.setIndicatorAreaHovered(hovered) } } + + ActiveIndicatorBlock { + id: activeHorizontalBlock + indicatorsModule: root + indicatorModel: activeIndicatorModel + horizontal: true + reportActiveState: !root.vertical + } } Column { @@ -233,14 +237,6 @@ BarWidget { onHoveredChanged: root.setIndicatorAreaHovered(hovered) } - ActiveIndicatorBlock { - id: activeVerticalBlock - indicatorsModule: root - indicatorModel: activeIndicatorModel - horizontal: false - reportActiveState: root.vertical - } - Item { id: inactiveVerticalArea @@ -264,6 +260,14 @@ BarWidget { onHoveredChanged: root.setIndicatorAreaHovered(hovered) } } + + ActiveIndicatorBlock { + id: activeVerticalBlock + indicatorsModule: root + indicatorModel: activeIndicatorModel + horizontal: false + reportActiveState: root.vertical + } } HoverHandler { diff --git a/shell/plugins/bar/widgets/SystemUpdate.qml b/shell/plugins/bar/widgets/SystemUpdate.qml index e1b2078e..a4f9020f 100644 --- a/shell/plugins/bar/widgets/SystemUpdate.qml +++ b/shell/plugins/bar/widgets/SystemUpdate.qml @@ -28,11 +28,11 @@ BarWidget { target: "omarchy.system-update" function refresh(): void { - root.refresh() + root.broadcast("refresh") } function clear(): void { - root.clear() + root.broadcast("clear") } } @@ -59,7 +59,7 @@ BarWidget { text: "\uf021" slotSize: Style.bar.statusSlot fontSize: Style.font.caption - tooltipText: "" + tooltipText: "Pending Omarchy Updates" onPressed: root.runUpdate() } } diff --git a/shell/plugins/bar/widgets/Tray.qml b/shell/plugins/bar/widgets/Tray.qml index 0355c93e..29487fc2 100644 --- a/shell/plugins/bar/widgets/Tray.qml +++ b/shell/plugins/bar/widgets/Tray.qml @@ -1,5 +1,6 @@ import Quickshell import QtQuick +import QtQuick.Controls import QtQuick.Effects import Quickshell.Services.SystemTray import qs.Commons @@ -446,116 +447,129 @@ BarWidget { contentWidth: trayMenuPopup.fittedContentWidth(Style.space(232)) contentHeight: trayMenuPopup.fittedContentHeight(trayMenuColumn.implicitHeight, Style.space(420)) - Column { - id: trayMenuColumn + Flickable { + id: trayMenuFlick anchors.fill: parent - spacing: 0 + contentWidth: width + contentHeight: trayMenuColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + flickableDirection: Flickable.VerticalFlick + interactive: contentHeight > height - Repeater { - model: trayMenuOpener.children + ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded } - delegate: Item { - id: menuRow - required property var modelData - required property int index + Column { + id: trayMenuColumn + width: trayMenuFlick.width + spacing: 0 - readonly property string rowText: String(modelData.text || "") - readonly property string activeTitle: root.activeTrayItem ? String(root.activeTrayItem.title || root.activeTrayItem.id || "") : "" - readonly property bool rootTitleEntry: index === 0 && modelData.hasChildren && rowText.toLowerCase() === activeTitle.toLowerCase() - readonly property bool leadingSeparator: modelData.isSeparator && index <= 1 - readonly property bool hiddenRow: rootTitleEntry || leadingSeparator + Repeater { + model: trayMenuOpener.children - visible: !hiddenRow - width: trayMenuColumn.width - implicitHeight: hiddenRow ? 0 : (modelData.isSeparator ? Style.space(11) : Style.space(30)) - opacity: modelData.enabled ? 1.0 : 0.45 + delegate: Item { + id: menuRow + required property var modelData + required property int index - Rectangle { - visible: menuRow.modelData.isSeparator - anchors.left: parent.left - anchors.leftMargin: Style.space(10) - anchors.right: parent.right - anchors.rightMargin: Style.space(10) - anchors.verticalCenter: parent.verticalCenter - height: 1 - color: Color.popups.border - opacity: 0.45 - } + readonly property string rowText: String(modelData.text || "") + readonly property string activeTitle: root.activeTrayItem ? String(root.activeTrayItem.title || root.activeTrayItem.id || "") : "" + readonly property bool rootTitleEntry: index === 0 && modelData.hasChildren && rowText.toLowerCase() === activeTitle.toLowerCase() + readonly property bool leadingSeparator: modelData.isSeparator && index <= 1 + readonly property bool hiddenRow: rootTitleEntry || leadingSeparator - Rectangle { - visible: !menuRow.modelData.isSeparator - anchors.fill: parent - radius: Math.max(2, Style.cornerRadius) - color: rowMouse.containsMouse && menuRow.modelData.enabled ? Style.hoverFillFor(root.foreground, root.foreground) : "transparent" - } + visible: !hiddenRow + width: trayMenuColumn.width + implicitHeight: hiddenRow ? 0 : (modelData.isSeparator ? Style.space(11) : Style.space(30)) + opacity: modelData.enabled ? 1.0 : 0.45 - Text { - visible: !menuRow.modelData.isSeparator && menuRow.modelData.buttonType !== QsMenuButtonType.None - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - width: Style.space(22) - horizontalAlignment: Text.AlignHCenter - text: menuRow.modelData.checkState === Qt.Checked ? "\uf00c" : "" - color: root.foreground - font.family: root.fontFamily - font.pixelSize: Style.font.bodySmall - } + Rectangle { + visible: menuRow.modelData.isSeparator + anchors.left: parent.left + anchors.leftMargin: Style.space(10) + anchors.right: parent.right + anchors.rightMargin: Style.space(10) + anchors.verticalCenter: parent.verticalCenter + height: 1 + color: Color.popups.border + opacity: 0.45 + } - Image { - id: menuIcon - visible: !menuRow.modelData.isSeparator && String(menuRow.modelData.icon || "") !== "" - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: Style.space(24) - width: Style.space(16) - height: Style.space(16) - fillMode: Image.PreserveAspectFit - // Decode at physical pixels: IconImage uses the logical size, - // which leaves PNG icons upscaled and blurry on HiDPI displays. - sourceSize.width: width * Screen.devicePixelRatio - sourceSize.height: height * Screen.devicePixelRatio - source: menuRow.modelData.icon - } + Rectangle { + visible: !menuRow.modelData.isSeparator + anchors.fill: parent + radius: Math.max(2, Style.cornerRadius) + color: rowMouse.containsMouse && menuRow.modelData.enabled ? Style.hoverFillFor(root.foreground, root.foreground) : "transparent" + } - Text { - visible: !menuRow.modelData.isSeparator - anchors.verticalCenter: parent.verticalCenter - anchors.left: parent.left - anchors.leftMargin: menuIcon.visible ? Style.space(46) : Style.space(28) - anchors.right: submenuGlyph.left - anchors.rightMargin: Style.space(8) - text: menuRow.rowText - color: root.foreground - font.family: root.fontFamily - font.pixelSize: Style.font.bodySmall - elide: Text.ElideRight - } + Text { + visible: !menuRow.modelData.isSeparator && menuRow.modelData.buttonType !== QsMenuButtonType.None + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + width: Style.space(22) + horizontalAlignment: Text.AlignHCenter + text: menuRow.modelData.checkState === Qt.Checked ? "\uf00c" : "" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } - Text { - id: submenuGlyph - visible: !menuRow.modelData.isSeparator && menuRow.modelData.hasChildren - anchors.verticalCenter: parent.verticalCenter - anchors.right: parent.right - anchors.rightMargin: Style.space(10) - text: "\u203a" - color: root.foreground - font.family: root.fontFamily - font.pixelSize: Style.font.bodySmall - } + Image { + id: menuIcon + visible: !menuRow.modelData.isSeparator && String(menuRow.modelData.icon || "") !== "" + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: Style.space(24) + width: Style.space(16) + height: Style.space(16) + fillMode: Image.PreserveAspectFit + // Decode at physical pixels: IconImage uses the logical size, + // which leaves PNG icons upscaled and blurry on HiDPI displays. + sourceSize.width: width * Screen.devicePixelRatio + sourceSize.height: height * Screen.devicePixelRatio + source: menuRow.modelData.icon + } - MouseArea { - id: rowMouse - anchors.fill: parent - hoverEnabled: true - enabled: !menuRow.modelData.isSeparator && menuRow.modelData.enabled - cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor - onClicked: { - if (menuRow.modelData.hasChildren) { - var point = menuRow.QsWindow.contentItem.mapFromItem(menuRow, menuRow.width, menuRow.height / 2) - menuRow.modelData.display(menuRow.QsWindow.window, point.x, point.y) - } else { - menuRow.modelData.triggered() - root.close() + Text { + visible: !menuRow.modelData.isSeparator + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: menuIcon.visible ? Style.space(46) : Style.space(28) + anchors.right: submenuGlyph.left + anchors.rightMargin: Style.space(8) + text: menuRow.rowText + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + elide: Text.ElideRight + } + + Text { + id: submenuGlyph + visible: !menuRow.modelData.isSeparator && menuRow.modelData.hasChildren + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: Style.space(10) + text: "\u203a" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + enabled: !menuRow.modelData.isSeparator && menuRow.modelData.enabled + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + if (menuRow.modelData.hasChildren) { + var point = menuRow.QsWindow.contentItem.mapFromItem(menuRow, menuRow.width, menuRow.height / 2) + menuRow.modelData.display(menuRow.QsWindow.window, point.x, point.y) + } else { + menuRow.modelData.triggered() + root.close() + } } } } diff --git a/shell/plugins/launcher/Launcher.qml b/shell/plugins/launcher/Launcher.qml deleted file mode 100644 index b38b750d..00000000 --- a/shell/plugins/launcher/Launcher.qml +++ /dev/null @@ -1,665 +0,0 @@ -import Quickshell -import Quickshell.Io -import Quickshell.Wayland -import QtQuick -import qs.Commons -import qs.Ui -import "LauncherSearch.js" as LauncherSearch - -Item { - id: root - - property string omarchyPath: Quickshell.env("OMARCHY_PATH") - property var shell: null - property var manifest: null - - property bool opened: false - property string placeholder: "\uf002 Search..." - property string filterText: "" - property int selectedIndex: 0 - property bool cursorActive: true - property var filteredEntries: [] - property int launchSerial: 0 - property int launchToplevelCount: 0 - property var launchActiveToplevel: null - property bool launchOsdOpen: false - property string launchOsdMessage: "" - property var configuredHiddenEntryIds: ({}) - property var desktopHiddenEntryIds: ({}) - property bool deleteConfirmOpen: false - property var deleteEntry: null - - // Maps an icon name to a file on disk (e.g. "omacut" -> ".../apps/omacut.svg"). - // Used as a fallback for icons that Qt's themed lookup misses because they were - // installed after this process started (its icon cache never re-scans). Refreshed - // whenever the app list changes, so newly installed apps get their icon live. - property var iconIndex: ({}) - property var pendingIconIndex: ({}) - - // Bound to the central [launcher] section in shell.toml via Color.qml. - // Each color already includes its alpha companion (composed in the - // singleton), so consumers can drop them straight into a Rectangle. - property color background: Color.launcher.background - property color foreground: Color.launcher.text - property color border: Color.launcher.border - property var borderSpec: Border.surfaceSpec("launcher", "border", border, 2) - property color scrim: Color.launcher.scrim - property color selectedBackground: Color.launcher.selectedBackground - property color selectedText: Color.launcher.selectedText - property color selectedBorder: Color.launcher.selectedBorder - property var selectedBorderSpec: Border.surfaceSpec("launcher", "selected-border", selectedBorder, 0) - readonly property real rowReservedBorderLeft: Border.left(selectedBorderSpec) - readonly property real rowReservedBorderRight: Border.right(selectedBorderSpec) - property string fontFamily: Style.font.menuFamily - - property int cardWidth: 644 - property int cardHeight: 400 - property int contentMargin: 20 - property int contentSpacing: 10 - property int searchHeight: 44 - property int rowHeight: 50 - property int iconSlotWidth: 44 - property int iconSize: 24 - readonly property int listHeight: cardHeight - contentMargin * 2 - searchHeight - contentSpacing - - function open(payloadJson) { - var payload = ({}) - try { payload = JSON.parse(payloadJson || "{}") } catch (e) { payload = ({}) } - - root.placeholder = payload.placeholder || "\uf002 Search..." - root.cardWidth = Math.max(300, Number(payload.width || 644)) - var requestedListHeight = Number(payload.listHeight || payload.maxHeight || 0) - root.cardHeight = requestedListHeight > 0 - ? root.contentMargin * 2 + root.searchHeight + root.contentSpacing + requestedListHeight - : 400 - - root.filterText = payload.query || "" - root.selectedIndex = 0 - root.cursorActive = true - root.disarmHover() - root.opened = true - root.rebuildDisplay() - // The shell may start before first-install packages have finished placing - // their icons. Refresh here even when the desktop entry list did not change. - if (!iconIndexScan.running) iconIndexScan.running = true - Qt.callLater(function() { keyCatcher.forceActiveFocus() }) - } - - function close() { - root.opened = false - } - - function dismiss() { - root.deleteConfirmOpen = false - root.deleteEntry = null - root.opened = false - if (root.shell && typeof root.shell.hide === "function") - root.shell.hide((root.manifest && root.manifest.id) || "omarchy.launcher") - } - - function iconSource(icon) { - var value = String(icon || "") - if (value.length === 0) return Quickshell.iconPath("application-x-executable", true) - if (value.indexOf("file://") === 0 || value.indexOf("image://") === 0) return value - if (value.charAt(0) === "/") return Util.fileUrl(value) - // Prefer the context-limited app/device index. An unconstrained themed - // lookup can resolve an app name such as "zoom" to an action icon instead. - var found = root.iconIndex[value] - if (found) return Util.fileUrl(found) - var themed = Quickshell.iconPath(value, true) - if (themed.length > 0) return themed - return Quickshell.iconPath("application-x-executable", true) - } - - function entryName(entry) { - return LauncherSearch.entryName(entry) - } - - function entrySubtext(entry) { - return LauncherSearch.entrySubtext(entry) - } - - function entrySortKey(entry) { - return LauncherSearch.entrySortKey(entry) - } - - function toplevelCount() { - try { return ToplevelManager.toplevels.values.length } catch (e) { return 0 } - } - - function entrySearchText(entry) { - return LauncherSearch.entrySearchText(entry) - } - - function disarmHover() { - pointerGate.reset() - } - - function selectFromPointer(index, item, mouse) { - if (!pointerGate.moved(item, mouse)) return - root.cursorActive = true - root.selectedIndex = index - } - - function isHiddenEntry(entry) { - var id = String((entry && entry.id) || "") - return root.configuredHiddenEntryIds[id] === true || root.desktopHiddenEntryIds[id] === true - } - - function normalizeDesktopId(id) { - var value = String(id || "").trim() - if (value.slice(-8) === ".desktop") value = value.slice(0, -8) - return value - } - - function loadConfiguredHides(rawText) { - var next = ({}) - var lines = String(rawText || "").split(/\n/) - for (var i = 0; i < lines.length; i++) { - var id = root.normalizeDesktopId(lines[i]) - if (id.length > 0) next[id] = true - } - root.configuredHiddenEntryIds = next - if (root.opened) root.rebuildDisplay() - } - - function loadDesktopHiddenEntries(rawText) { - var next = ({}) - var lines = String(rawText || "").split(/\n/) - for (var i = 0; i < lines.length; i++) { - var id = root.normalizeDesktopId(lines[i]) - if (id.length > 0) next[id] = true - } - root.desktopHiddenEntryIds = next - if (root.opened) root.rebuildDisplay() - } - - function iconIndexScanCommand() { - // List app/device icons across the XDG icon dirs and /usr/share/pixmaps as - // "" lines. Some desktop entries, such as Print Settings, use device - // icons like "printer" instead of app icons. SVGs are emitted before PNGs - // so the parser, which keeps the first hit per name, prefers scalable icons. - return [ - 'dirs="$HOME/.icons $HOME/.local/share/icons";', - 'IFS=":"; for d in ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do dirs="$dirs $d/icons"; done; unset IFS;', - 'for ext in svg png; do', - ' for base in $dirs; do', - ' [[ -d $base ]] && find "$base" \\( -path "*/apps/*" -o -path "*/devices/*" \\) -name "*.$ext" 2>/dev/null;', - ' done;', - ' find /usr/share/pixmaps -maxdepth 1 -name "*.$ext" 2>/dev/null;', - 'done' - ].join(' ') - } - - function indexIconLine(path) { - var value = String(path || "").trim() - if (value.length === 0) return - var slash = value.lastIndexOf("/") - var file = slash >= 0 ? value.slice(slash + 1) : value - var dot = file.lastIndexOf(".") - var name = dot > 0 ? file.slice(0, dot) : file - if (name.length > 0 && root.pendingIconIndex[name] === undefined) - root.pendingIconIndex[name] = value - } - - function hiddenEntryScanCommand() { - var desktop = [Quickshell.env("XDG_CURRENT_DESKTOP"), Quickshell.env("XDG_SESSION_DESKTOP"), Quickshell.env("DESKTOP_SESSION")].filter(function(v) { return String(v || "").length > 0 }).join(":") - var script = root.omarchyPath + "/shell/plugins/launcher/hidden-entries.sh" - return Util.shellQuote(script) + " " + Util.shellQuote(desktop) - } - - function fuzzyScore(entry, query) { - return LauncherSearch.fuzzyScore(entry, query) - } - - function sortedEntries(query) { - var values = DesktopEntries.applications.values || [] - return LauncherSearch.sortedEntries(values, query, function(entry) { return root.isHiddenEntry(entry) }) - } - - function rebuildDisplay() { - displayModel.clear() - var rows = root.sortedEntries(root.filterText) - var entries = [] - var count = Math.min(rows.length, 256) - for (var i = 0; i < count; i++) { - var entry = rows[i].entry - entries.push(entry) - displayModel.append({ - name: root.entryName(entry), - subtext: root.entrySubtext(entry), - icon: String(entry.icon || "") - }) - } - root.filteredEntries = entries - - if (displayModel.count === 0) root.selectedIndex = 0 - else if (root.selectedIndex >= displayModel.count) root.selectedIndex = displayModel.count - 1 - else if (root.selectedIndex < 0) root.selectedIndex = 0 - - Qt.callLater(function() { - if (displayModel.count > 0) resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain) - }) - } - - function setFilter(nextFilter) { - root.filterText = nextFilter - root.selectedIndex = 0 - root.cursorActive = true - root.disarmHover() - root.rebuildDisplay() - } - - function select(delta) { - if (displayModel.count === 0) return - root.cursorActive = true - root.disarmHover() - root.selectedIndex = (root.selectedIndex + delta + displayModel.count) % displayModel.count - resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain) - } - - function activateIndex(index) { - if (root.deleteConfirmOpen) return - if (index < 0 || index >= root.filteredEntries.length) return - var entry = root.filteredEntries[index] - if (!entry) return - var desktopId = String(entry.id || "") - if (!desktopId) return - - root.beginLaunchFeedback(entry) - root.dismiss() - Util.execDetached("gtk-launch " + Util.shellQuote(desktopId)) - } - - function requestDeleteIndex(index) { - if (index < 0 || index >= root.filteredEntries.length) return - var entry = root.filteredEntries[index] - if (!entry) return - root.deleteEntry = entry - deleteConfirm.selectedIndex = 1 - root.deleteConfirmOpen = true - } - - function cancelDelete() { - root.deleteConfirmOpen = false - root.deleteEntry = null - deleteConfirm.selectedIndex = 1 - root.disarmHover() - Qt.callLater(function() { keyCatcher.forceActiveFocus() }) - } - - function confirmDelete() { - var entry = root.deleteEntry - if (!entry) return - - var desktopId = String(entry.id || "") - var name = root.entryName(entry) - var command = Util.shellQuote(root.omarchyPath + "/bin/omarchy-remove-launcher-entry") + " " + Util.shellQuote(desktopId) + " " + Util.shellQuote(name) - root.dismiss() - Util.execDetached(command) - } - - function beginLaunchFeedback(entry) { - root.launchSerial++ - root.launchToplevelCount = root.toplevelCount() - root.launchActiveToplevel = ToplevelManager.activeToplevel - root.launchOsdOpen = false - root.launchOsdMessage = "Launching " + root.entryName(entry) + "…" - launchDelay.restart() - launchTimeout.restart() - } - - function closeLaunchFeedback(serial) { - if (serial !== root.launchSerial) return - launchDelay.stop() - launchTimeout.stop() - if (root.launchOsdOpen) { - Quickshell.execDetached(["omarchy-shell", "osd", "close"]) - root.launchOsdOpen = false - } - } - - function maybeFinishLaunchFeedback() { - if (!launchDelay.running && !launchTimeout.running && !root.launchOsdOpen) return - if (root.toplevelCount() <= root.launchToplevelCount && ToplevelManager.activeToplevel === root.launchActiveToplevel) return - root.closeLaunchFeedback(root.launchSerial) - } - - ListModel { id: displayModel } - - Process { - id: hiddenEntryScan - command: ["bash", "-lc", root.hiddenEntryScanCommand()] - stdout: SplitParser { onRead: function(line) { hiddenEntryOutput.text += line + "\n" } } - onStarted: hiddenEntryOutput.text = "" - onExited: root.loadDesktopHiddenEntries(hiddenEntryOutput.text) - } - - Process { - id: iconIndexScan - command: ["bash", "-lc", root.iconIndexScanCommand()] - stdout: SplitParser { onRead: function(line) { root.indexIconLine(line) } } - onStarted: root.pendingIconIndex = ({}) - // Swapping the property re-evaluates every iconSource() binding, so - // newly found icons appear without rebuilding the list. - onExited: root.iconIndex = root.pendingIconIndex - } - - // Coalesces bursts of app-list changes (a package install touches many - // entries) into a single rescan. - Timer { - id: iconIndexDebounce - interval: 750 - onTriggered: if (!iconIndexScan.running) iconIndexScan.running = true - } - - QtObject { - id: hiddenEntryOutput - property string text: "" - } - - FileView { - id: launcherHidesFile - path: root.omarchyPath + "/default/omarchy/launcher.hides" - watchChanges: true - printErrors: false - onLoaded: root.loadConfiguredHides(text()) - onFileChanged: root.loadConfiguredHides(text()) - onLoadFailed: root.loadConfiguredHides("") - } - - PointerMoveGate { - id: pointerGate - referenceItem: card - } - - Connections { - target: ToplevelManager.toplevels - function onValuesChanged() { root.maybeFinishLaunchFeedback() } - } - - Connections { - target: ToplevelManager - function onActiveToplevelChanged() { root.maybeFinishLaunchFeedback() } - } - - Timer { - id: launchDelay - interval: 2000 - onTriggered: { - if (root.toplevelCount() > root.launchToplevelCount || ToplevelManager.activeToplevel !== root.launchActiveToplevel) return - root.launchOsdOpen = true - Quickshell.execDetached(["omarchy-shell", "osd", "show", JSON.stringify({ icon: "󱓞", message: root.launchOsdMessage, duration: 0 })]) - } - } - - Timer { - id: launchTimeout - interval: 15000 - onTriggered: root.closeLaunchFeedback(root.launchSerial) - } - - Connections { - target: DesktopEntries.applications - function onValuesChanged() { - hiddenEntryScan.running = true - iconIndexDebounce.restart() - if (root.opened) root.rebuildDisplay() - } - } - - Component.onCompleted: { - hiddenEntryScan.running = true - iconIndexScan.running = true - } - - PanelWindow { - id: panel - visible: root.opened - anchors { top: true; bottom: true; left: true; right: true } - color: "transparent" - WlrLayershell.namespace: "omarchy-launcher" - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive - exclusionMode: ExclusionMode.Ignore - - Rectangle { - anchors.fill: parent - color: root.scrim - } - - MouseArea { - anchors.fill: parent - onClicked: root.dismiss() - } - - BorderSurface { - id: card - width: Math.min(root.cardWidth, panel.width - Style.gapsOut * 2) - height: Math.min(root.cardHeight, panel.height - Style.gapsOut * 2) - radius: Style.cornerRadius - anchors.centerIn: parent - color: root.background - borderSpec: root.borderSpec - padding: root.contentMargin - clip: true - - MouseArea { anchors.fill: parent; onClicked: {} } - - Item { - id: keyCatcher - anchors.fill: parent - z: root.deleteConfirmOpen ? 20 : 0 - focus: true - - Keys.priority: Keys.BeforeItem - Keys.onPressed: function(event) { - if (root.deleteConfirmOpen) { - if (deleteConfirm.handleKey(event)) event.accepted = true - return - } - - if (event.key === Qt.Key_Escape) { - if (root.filterText.length > 0) root.setFilter("") - else root.dismiss() - event.accepted = true - } else if (Util.editsFilter(event, root.filterText)) { - root.setFilter(Util.editedFilter(event, root.filterText)) - event.accepted = true - } else if (event.key === Qt.Key_Up) { - root.select(-1) - event.accepted = true - } else if (event.key === Qt.Key_Down) { - root.select(1) - event.accepted = true - } else if (event.key === Qt.Key_PageUp) { - root.select(-6) - event.accepted = true - } else if (event.key === Qt.Key_PageDown) { - root.select(6) - event.accepted = true - } else if (event.key === Qt.Key_Home) { - if (displayModel.count > 0) { - root.cursorActive = true - root.disarmHover() - root.selectedIndex = 0 - resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain) - } - event.accepted = true - } else if (event.key === Qt.Key_End) { - if (displayModel.count > 0) { - root.cursorActive = true - root.disarmHover() - root.selectedIndex = displayModel.count - 1 - resultList.positionViewAtIndex(root.selectedIndex, ListView.Contain) - } - event.accepted = true - } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { - root.activateIndex(root.selectedIndex) - event.accepted = true - } else if (event.key === Qt.Key_Delete) { - root.requestDeleteIndex(root.selectedIndex) - event.accepted = true - } else if (event.text && event.text.length === 1 && event.text.charCodeAt(0) >= 32 && event.text.charCodeAt(0) !== 127 && (event.modifiers === Qt.NoModifier || event.modifiers === Qt.ShiftModifier)) { - root.setFilter(root.filterText + event.text) - event.accepted = true - } - } - - ConfirmDialog { - id: deleteConfirm - - anchors.fill: parent - opened: root.deleteConfirmOpen - z: 10 - message: "Do you want to uninstall " + root.entryName(root.deleteEntry) + "?" - confirmText: "Uninstall" - background: root.background - foreground: root.foreground - scrim: root.scrim - selectedBackground: root.selectedBackground - selectedText: root.selectedText - fontFamily: root.fontFamily - cornerRadius: Style.cornerRadius - onCanceled: root.cancelDelete() - onConfirmed: root.confirmDelete() - } - } - - Column { - anchors.fill: parent - anchors.topMargin: card.contentTopInset - anchors.rightMargin: card.contentRightInset - anchors.bottomMargin: card.contentBottomInset - anchors.leftMargin: card.contentLeftInset - spacing: root.contentSpacing - - Rectangle { - width: parent.width - height: root.searchHeight - radius: 0 - color: root.background - - Text { - anchors.left: parent.left - anchors.leftMargin: 10 - anchors.right: parent.right - anchors.rightMargin: 10 - anchors.verticalCenter: parent.verticalCenter - text: root.filterText || root.placeholder - color: root.foreground - opacity: root.filterText ? 1 : 0.5 - font.family: root.fontFamily - font.pixelSize: 18 - elide: Text.ElideRight - } - } - - Item { - width: parent.width - height: parent.height - root.searchHeight - root.contentSpacing - - ListView { - id: resultList - anchors.fill: parent - model: displayModel - clip: true - spacing: 0 - boundsBehavior: Flickable.StopAtBounds - - delegate: BorderSurface { - id: row - required property int index - required property string name - required property string subtext - required property string icon - - readonly property bool hasCursor: root.cursorActive && row.index === root.selectedIndex - - width: ListView.view.width - height: root.rowHeight - radius: 0 - color: row.hasCursor ? root.selectedBackground : "transparent" - borderSpec: row.hasCursor ? root.selectedBorderSpec : Border.none() - - Item { - id: iconSlot - anchors.left: parent.left - anchors.leftMargin: root.rowReservedBorderLeft + 14 - anchors.verticalCenter: parent.verticalCenter - width: root.iconSlotWidth - height: parent.height - - Image { - id: appIcon - anchors.centerIn: parent - width: root.iconSize - height: root.iconSize - fillMode: Image.PreserveAspectFit - // Decode at physical pixels: IconImage uses the logical size, - // which leaves PNG icons upscaled and blurry on HiDPI displays. - sourceSize.width: root.iconSize * Screen.devicePixelRatio - sourceSize.height: root.iconSize * Screen.devicePixelRatio - source: root.iconSource(row.icon) - asynchronous: true - } - - Text { - anchors.centerIn: parent - visible: appIcon.status === Image.Error - text: "?" - color: row.hasCursor ? root.selectedText : root.foreground - font.family: root.fontFamily - font.pixelSize: 18 - } - } - - Text { - anchors.left: iconSlot.right - anchors.leftMargin: 14 - anchors.right: parent.right - anchors.rightMargin: root.rowReservedBorderRight + 14 - anchors.verticalCenter: parent.verticalCenter - text: row.name - color: row.hasCursor ? root.selectedText : root.foreground - font.family: root.fontFamily - font.pixelSize: 18 - elide: Text.ElideRight - } - - MouseArea { - id: mouseArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onEntered: root.selectFromPointer(row.index, row, { - x: mouseArea.mouseX, - y: mouseArea.mouseY - }) - onPositionChanged: function(mouse) { - root.selectFromPointer(row.index, row, mouse) - } - onClicked: { - root.cursorActive = true - root.selectedIndex = row.index - root.activateIndex(row.index) - } - } - } - } - - Text { - anchors.top: parent.top - anchors.left: parent.left - anchors.leftMargin: 14 - visible: displayModel.count === 0 - text: "No Results" - color: root.foreground - font.family: root.fontFamily - font.pixelSize: 18 - } - } - } - } - } -} diff --git a/shell/plugins/launcher/manifest.json b/shell/plugins/launcher/manifest.json deleted file mode 100644 index a036bc57..00000000 --- a/shell/plugins/launcher/manifest.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "schemaVersion": 1, - "id": "omarchy.launcher", - "name": "Launcher", - "version": "1.0.0", - "author": "Omarchy", - "description": "A Quickshell-powered launcher", - "kinds": [ - "overlay" - ], - "keepLoaded": true, - "entryPoints": { - "overlay": "Launcher.qml" - } -} diff --git a/shell/plugins/lock/LockView.qml b/shell/plugins/lock/LockView.qml index c6c16f7d..7b0b0ae0 100644 --- a/shell/plugins/lock/LockView.qml +++ b/shell/plugins/lock/LockView.qml @@ -24,6 +24,9 @@ Item { readonly property int fieldFontSize: Math.round(Style.font.heading * 1.125) readonly property int passwordDotFontSize: Math.round(Style.font.heading * 1.33) readonly property int passwordDotLetterSpacing: Math.round(Style.font.heading * 0.19) + // Space to keep clear on each side of the field for the fingerprint icon + // (icon width plus a gap) so the centered dots never run under it. + readonly property real fingerprintReserve: fingerprintConfigured ? Math.round(fingerprintIcon.implicitWidth + 12) : 0 // Shrink the dots to fit once the password outgrows the field, so every // keystroke stays visible — otherwise long passwords clip with no feedback. readonly property real passwordDotScale: dotMetrics.advanceWidth > 0 @@ -130,9 +133,11 @@ Item { id: passwordInput anchors.fill: parent anchors.topMargin: inputField.borderTop - anchors.rightMargin: inputField.borderRight + 18 + // Reserve the fingerprint icon's width on both sides so the centered + // dots stay symmetric and never slide under the icon as they grow. + anchors.rightMargin: inputField.borderRight + 18 + root.fingerprintReserve anchors.bottomMargin: inputField.borderBottom - anchors.leftMargin: inputField.borderLeft + 18 + anchors.leftMargin: inputField.borderLeft + 18 + root.fingerprintReserve verticalAlignment: TextInput.AlignVCenter horizontalAlignment: TextInput.AlignHCenter activeFocusOnPress: true @@ -190,6 +195,24 @@ Item { verticalAlignment: Text.AlignVCenter elide: Text.ElideRight } + + // Fingerprint hint pinned inside the field's right edge when a sensor is + // enrolled, so the user knows they can touch to unlock instead of typing. + // Matches hyprlock, which draws its fingerprint icon in the same spot. + Text { + id: fingerprintIcon + objectName: "fingerprintIndicator" + anchors.right: parent.right + anchors.rightMargin: inputField.borderRight + 18 + anchors.verticalCenter: parent.verticalCenter + visible: root.fingerprintConfigured + text: "󰈷" + color: Color.lock.placeholder + font.family: Style.font.family + font.pixelSize: Math.round(root.fieldFontSize * 1.1) + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + } } } } diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index a0199074..06861053 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -11,6 +11,8 @@ Item { // Injected by omarchy-shell when this plugin is summoned. property string omarchyPath: Quickshell.env("OMARCHY_PATH") + property var shell: null + property var manifest: null // Plugin lifecycle hooks. The host calls open(payloadJson) after // `omarchy-shell shell summon omarchy.menu ...` and close() when hidden. @@ -72,6 +74,13 @@ Item { property var providersLoaded: ({}) property var providerQueue: [] property int providerRevision: 0 + + // Shared application engine (entries, hidden filters, icons, launch, + // removal), owned by the shell and also used by the standalone launcher. + readonly property var appLibrary: root.shell ? root.shell.appLibrary : null + property bool deleteConfirmOpen: false + property var deleteTarget: null + onOpenedChanged: if (!opened) { deleteConfirmOpen = false; deleteTarget = null } // Bound to the central [menu] section in shell.toml via Color.qml. // Each color already includes its alpha companion (composed in the // singleton), so consumers can drop them straight into a Rectangle. @@ -100,7 +109,7 @@ Item { property int visibleRowsHeight: root.dmenuActive ? dmenuRowListHeight(layoutSerial, displayModel.count, filterText) : rowListHeight(layoutSerial, displayModel.count, filterText, searchDivider) property int cardHeight: root.dmenuActive ? Math.min(contentMargin * 2 + headerHeight + (mode === "input" ? 0 : contentSpacing + visibleRowsHeight), panel.height - Style.gapsOut * 2) - : Math.min(Math.max(Style.space(220), contentMargin * 2 + headerHeight + contentSpacing + visibleRowsHeight), panel.height - Style.gapsOut * 2) + : Math.min(contentMargin * 2 + headerHeight + contentSpacing + visibleRowsHeight, panel.height - Style.gapsOut * 2) function finishRequest(selection) { if (!root.requestActive || !root.doneFile) { @@ -231,9 +240,57 @@ Item { return MenuModel.slugify(value) } + // The apps provider is QML-native: rows come from the shared AppLibrary + // (DesktopEntries) instead of a bash enumeration, so they carry image + // icons, launch feedback, and uninstall support like the launcher. + function mergeAppRows() { + if (!root.appLibrary) return + + var rows = root.appLibrary.sortedEntries("") + var appRows = [] + for (var j = 0; j < rows.length; j++) { + var entry = rows[j].entry + var appId = String(entry.id || "") + if (!appId) continue + var subtext = root.appLibrary.entrySubtext(entry) + var aliases = subtext ? [subtext] : [] + try { + if (entry.keywords && typeof entry.keywords.join === "function") aliases = aliases.concat(entry.keywords) + } catch (e) { } + appRows.push({ + id: "apps." + appId, + parent: "apps", + kind: "app", + icon: "", + appIcon: String(entry.icon || ""), + appId: appId, + label: root.appLibrary.entryName(entry), + title: "", + target: "", + description: subtext, + action: "", + provider: "", + aliases: aliases, + when: "", + checked: "", + order: 0 + }) + } + + var merged = MenuModel.mergeAppRows(root.items, root.itemOrder, appRows) + root.items = merged.items + root.itemOrder = merged.itemOrder + if (root.opened) root.rebuildDisplay() + } + function startProviderForMenu(id) { var entry = root.item(id) if (!entry || !entry.provider || root.providersLoaded[id]) return + if (entry.provider === "apps") { + root.providersLoaded[id] = true + root.mergeAppRows() + return + } var spec = root.providers[entry.provider] if (!spec) return @@ -249,9 +306,8 @@ Item { function mergeProviderRows(rows, menuId, providerKey) { var spec = root.providers[providerKey] if (!spec) return - var changed = false var lines = String(rows || "").split("\n") - var nextOrder = root.itemOrder.slice() + var providerRows = [] for (var i = 0; i < lines.length; i++) { var line = lines[i].trim() if (!line) continue @@ -260,10 +316,8 @@ Item { var value = parts[1] || parts[0] || "" var current = parts[2] || "" if (!label) continue - var id = menuId + "." + root.slugify(value) - if (!root.items[id]) nextOrder.push(id) - root.items[id] = { - id: id, + providerRows.push({ + id: menuId + "." + root.slugify(value), parent: menuId, kind: "action", icon: (value === current) ? "✓" : (spec.icon || ""), @@ -276,11 +330,13 @@ Item { aliases: [], when: "", checked: "", - order: nextOrder.indexOf(id) - } - changed = true + order: 0 + }) } - root.itemOrder = nextOrder + var changed = providerRows.length > 0 + var merged = MenuModel.mergeRowsById(root.items, root.itemOrder, providerRows) + root.items = merged.items + root.itemOrder = merged.itemOrder if (changed && root.opened) root.rebuildDisplay() } @@ -301,6 +357,12 @@ Item { var entry = root.item(id) if (!entry || !entry.provider || root.providersLoaded[id]) return + // Native providers don't touch providerProc, so they never need to queue. + if (entry.provider === "apps") { + root.startProviderForMenu(id) + return + } + if (providerProc.running) { if (root.providerQueue.indexOf(id) < 0) root.providerQueue = root.providerQueue.concat([id]) return @@ -403,6 +465,8 @@ Item { kind: "dmenu", icon: "", iconFont: "", + appIcon: "", + appId: "", label: label, target: "", detail: "", @@ -477,6 +541,22 @@ Item { if (!root.isVisible(child)) continue rows.push(root.displayRow(child, child.description, child.order)) } + + // DesktopEntries can reorder its values when an application starts. + // Keep the Apps menu alphabetical independently of provider refreshes. + if (active === "apps") { + rows.sort(function(a, b) { + var aLabel = String(a.label || "").toLowerCase() + var bLabel = String(b.label || "").toLowerCase() + if (aLabel < bLabel) return -1 + if (aLabel > bLabel) return 1 + var aId = String(a.itemId || "") + var bId = String(b.itemId || "") + if (aId < bId) return -1 + if (aId > bId) return 1 + return 0 + }) + } } for (var k = 0; k < rows.length; k++) displayModel.append(rows[k]) @@ -505,6 +585,7 @@ Item { } function setFilter(nextFilter) { + panel.freezeCardTop() root.filterText = nextFilter root.selectedIndex = 0 root.cursorActive = root.mode !== "input" @@ -514,6 +595,7 @@ Item { } function setActiveMenu(id, pushHistory, fromPointer) { + panel.freezeCardTop() if (!root.item(id)) id = "root" if (pushHistory && id !== root.activeMenu) root.navStack = root.navStack.concat([root.activeMenu]) root.activeMenu = id @@ -542,6 +624,7 @@ Item { } function activateIndex(index, fromPointer) { + if (root.deleteConfirmOpen) return if (root.dmenuActive) { if (root.mode === "input") { root.applyDmenuSelection(root.filterText) @@ -557,11 +640,44 @@ Item { var row = displayModel.get(index) if (row.kind === "menu" || row.kind === "link") { root.setActiveMenu(row.target || row.itemId, true, fromPointer) + } else if (row.kind === "app") { + var appId = row.appId + var label = row.label + applySerial = requestSerial + opened = false + filterText = "" + if (root.appLibrary) root.appLibrary.launch(appId, label) } else { root.applySelected(row.itemId, row.action) } } + function requestDeleteSelected() { + if (!root.cursorActive || root.selectedIndex < 0 || root.selectedIndex >= displayModel.count) return + var row = displayModel.get(root.selectedIndex) + if (!row || row.kind !== "app") return + root.deleteTarget = { appId: row.appId, label: row.label } + deleteConfirm.selectedIndex = 1 + root.deleteConfirmOpen = true + } + + function cancelDelete() { + root.deleteConfirmOpen = false + root.deleteTarget = null + deleteConfirm.selectedIndex = 1 + root.disarmPointer() + Qt.callLater(function() { keyCatcher.forceActiveFocus() }) + } + + function confirmDelete() { + var target = root.deleteTarget + root.deleteConfirmOpen = false + root.deleteTarget = null + if (!target) return + root.cancel() + if (root.appLibrary) root.appLibrary.remove(target.appId, target.label) + } + function applyDmenuSelection(value) { applySerial = requestSerial opened = false @@ -600,6 +716,9 @@ Item { opened = true rebuildDisplay() loadProviderForMenu(activeMenu) + // The shell may start before first-install packages have finished placing + // their icons. Refresh here even when the desktop entry list did not change. + if (root.appLibrary) root.appLibrary.refreshIcons() Qt.callLater(function() { keyCatcher.forceActiveFocus() }) } @@ -707,6 +826,13 @@ Item { referenceItem: card } + Connections { + target: root.appLibrary + function onAppsChanged() { + if (root.providersLoaded["apps"]) root.mergeAppRows() + } + } + // The JSONC sources are watched so live edits to the default file (or the // user extension at ~/.config/omarchy/extensions/omarchy-menu.jsonc) take // effect without restarting the shell. @@ -797,6 +923,18 @@ Item { WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive exclusionMode: ExclusionMode.Ignore + // The card opens centered exactly as always. The first search keystroke + // or submenu move freezes the top line where it currently sits — from + // then on the card grows and shrinks downward instead of re-centering + // on every resize, which made the menu jump around. Closing unfreezes. + property int cardTop: -1 + readonly property int centeredTop: Math.max(Style.gapsOut, Math.round((height - root.cardHeight) / 2)) + readonly property int effectiveCardTop: cardTop >= 0 ? cardTop : centeredTop + function freezeCardTop() { + if (visible && cardTop < 0) cardTop = effectiveCardTop + } + onVisibleChanged: if (!visible) cardTop = -1 + Rectangle { anchors.fill: parent color: root.scrim @@ -810,9 +948,10 @@ Item { BorderSurface { id: card width: root.cardWidth - height: root.cardHeight + height: Math.min(root.cardHeight, panel.height - Style.gapsOut - panel.effectiveCardTop) radius: root.cornerRadius - anchors.centerIn: parent + anchors.horizontalCenter: parent.horizontalCenter + y: panel.effectiveCardTop color: root.background borderSpec: root.borderSpec padding: root.contentMargin @@ -822,11 +961,20 @@ Item { Item { id: keyCatcher anchors.fill: parent + z: root.deleteConfirmOpen ? 20 : 0 focus: true Keys.priority: Keys.BeforeItem Keys.onPressed: function(event) { - if (event.key === Qt.Key_Escape) { + if (root.deleteConfirmOpen) { + if (deleteConfirm.handleKey(event)) event.accepted = true + return + } + + if (event.key === Qt.Key_Delete) { + root.requestDeleteSelected() + event.accepted = true + } else if (event.key === Qt.Key_Escape) { if (root.filterText) root.setFilter("") else root.cancel() event.accepted = true @@ -860,6 +1008,25 @@ Item { event.accepted = true } } + + ConfirmDialog { + id: deleteConfirm + + anchors.fill: parent + opened: root.deleteConfirmOpen + z: 10 + message: "Do you want to uninstall " + ((root.deleteTarget && root.deleteTarget.label) || "") + "?" + confirmText: "Uninstall" + background: root.background + foreground: root.foreground + scrim: root.scrim + selectedBackground: root.selectedBackground + selectedText: root.selectedText + fontFamily: root.fontFamily + cornerRadius: root.cornerRadius + onCanceled: root.cancelDelete() + onConfirmed: root.confirmDelete() + } } Column { @@ -929,6 +1096,8 @@ Item { required property string kind required property string icon required property string iconFont + required property string appIcon + required property string appId required property string label required property string target required property string detail @@ -937,7 +1106,8 @@ Item { required property int childCount readonly property bool hasCursor: root.cursorActive && row.index === root.selectedIndex - readonly property bool hasIcon: row.icon.length > 0 + readonly property bool isApp: row.kind === "app" + readonly property bool hasIcon: row.icon.length > 0 || row.isApp width: ListView.view.width height: root.rowHeightForDetail(row.detail) @@ -958,7 +1128,7 @@ Item { Text { id: iconText - visible: row.hasIcon + visible: row.hasIcon && !row.isApp text: row.icon color: row.hasCursor ? root.selectedText : root.foreground font.family: row.iconFont.length > 0 ? row.iconFont : root.fontFamily @@ -971,6 +1141,23 @@ Item { y: contentColumn.y + labelText.y + (labelText.height - height) / 2 } + Image { + id: appIconImage + visible: row.isApp + width: Style.font.iconLarge + height: Style.font.iconLarge + fillMode: Image.PreserveAspectFit + // Decode at physical pixels — a logical-size decode leaves + // PNG icons upscaled and blurry on HiDPI displays. + sourceSize.width: width * Screen.devicePixelRatio + sourceSize.height: height * Screen.devicePixelRatio + source: row.isApp && root.appLibrary ? root.appLibrary.iconSource(row.appIcon) : "" + asynchronous: true + anchors.left: parent.left + anchors.leftMargin: root.rowReservedBorderLeft + Style.space(8) + (Style.space(36) - width) / 2 + y: contentColumn.y + labelText.y + (labelText.height - height) / 2 + } + Column { id: contentColumn anchors.left: row.hasIcon ? iconText.right : parent.left diff --git a/shell/plugins/menu/MenuModel.js b/shell/plugins/menu/MenuModel.js index b03fc981..556c1d77 100644 --- a/shell/plugins/menu/MenuModel.js +++ b/shell/plugins/menu/MenuModel.js @@ -94,6 +94,65 @@ function mergeMenuSources(defaultItems, userItems) { } } +// Both merges below return fresh items/itemOrder objects for the caller to +// assign in one go. They must never write into the maps they are handed: those +// live in QML `var` properties, and an in-place write into such an object is +// occasionally dropped by the engine — the key lands with an undefined value. +// A lost write used to leave an id in itemOrder with no item behind it, and +// the next merge then kept that orphan and appended a second row for the same +// app, so the launcher listed it twice (and again on every later rescan). + +// Swaps every app row for the current set. Rows keep the order they arrive in; +// ids already claimed (including duplicate desktop ids) are listed once. +function mergeAppRows(items, itemOrder, appRows) { + var source = items || ({}) + var order = Array.isArray(itemOrder) ? itemOrder : [] + var rows = Array.isArray(appRows) ? appRows : [] + var nextItems = ({}) + var nextOrder = [] + + for (var i = 0; i < order.length; i++) { + var id = order[i] + var existing = source[id] + // Orphans (an id with no item) are dropped rather than carried forward, + // so a single lost write cannot compound into a duplicate row. + if (!existing || existing.kind === "app") continue + nextItems[id] = existing + nextOrder.push(id) + } + + for (var j = 0; j < rows.length; j++) { + var row = rows[j] + if (!row || !row.id || nextItems[row.id]) continue + row.order = nextOrder.length + nextItems[row.id] = row + nextOrder.push(row.id) + } + + return { items: nextItems, itemOrder: nextOrder } +} + +// Adds or replaces rows by id, leaving every other item untouched. Used by the +// bash-backed providers, which contribute rows to one submenu at a time. +function mergeRowsById(items, itemOrder, rows) { + var source = items || ({}) + var incoming = Array.isArray(rows) ? rows : [] + var nextItems = ({}) + var nextOrder = (Array.isArray(itemOrder) ? itemOrder : []).slice() + + for (var k in source) nextItems[k] = source[k] + + for (var i = 0; i < incoming.length; i++) { + var row = incoming[i] + if (!row || !row.id) continue + if (!nextItems[row.id]) nextOrder.push(row.id) + nextItems[row.id] = row + row.order = nextOrder.indexOf(row.id) + } + + return { items: nextItems, itemOrder: nextOrder } +} + function item(items, id) { return items && items[id] ? items[id] : null } @@ -250,6 +309,9 @@ function searchScore(items, entry, query) { else if (descriptionTextMatches(needle, descriptionText)) score = 60 if (entry.kind === "menu" || entry.kind === "link") score -= 2 + // App rows sort after all menu items, so they lose the tiebreak below to an + // equal match. Outrank those, but stay inside the tier so better ones win. + if (entry.kind === "app") score -= 5 return score * 1000 + depthFor(items, entry.id) * 25 + entry.order } @@ -261,6 +323,8 @@ function displayRow(items, itemOrder, checkedResults, entry, detail, score, sect kind: entry.kind, icon: entry.icon, iconFont: entry.iconFont || "", + appIcon: entry.appIcon || "", + appId: entry.appId || "", label: labelFor(entry, checkedResults), target: target, detail: detail || "", @@ -280,6 +344,8 @@ if (typeof module !== "undefined") { normalizeItem: normalizeItem, parseMenuJsonc: parseMenuJsonc, mergeMenuSources: mergeMenuSources, + mergeAppRows: mergeAppRows, + mergeRowsById: mergeRowsById, item: item, slugify: slugify, depthFor: depthFor, diff --git a/shell/plugins/notifications/BarWidget.qml b/shell/plugins/notifications/BarWidget.qml deleted file mode 100644 index c517dfa6..00000000 --- a/shell/plugins/notifications/BarWidget.qml +++ /dev/null @@ -1,412 +0,0 @@ -import QtQuick -import QtQuick.Layouts -import Quickshell -import qs.Commons -import qs.Ui -import "NotificationLogic.js" as NotificationLogic - -BarWidget { - id: root - moduleName: "omarchy.notifications" - - - property bool popupOpen: false - function close() { popupOpen = false } - - // Always default to the pending tab when there's anything unseen, no - // matter how the popup was opened (click, keybind/IPC, or the close - // path). Keeps the spec from drifting based on the user's last manual - // tab selection. - onPopupOpenChanged: { - if (popupOpen) { - activeTab = pendingCount > 0 ? "pending" : "past" - } - } - - // Look up the long-running notifications service through the shell host. - readonly property var hostShell: bar && bar.shell ? bar.shell : null - readonly property var notificationService: hostShell?.firstPartyServiceFor("omarchy.notifications") - - function isChromiumDerived(app, appIcon) { - return NotificationLogic.isChromiumDerived(app, appIcon) - } - - function sanitizeBody(s, app, appIcon) { - return NotificationLogic.sanitizeBody(s, app, appIcon) - } - - function notificationIconSource(icon) { - var value = String(icon || "") - if (value.length === 0) return "" - if (value.indexOf("file://") === 0 || value.indexOf("image://") === 0) return value - if (value.charAt(0) === "/") return Util.fileUrl(value) - return Quickshell.iconPath(value, true) - } - - readonly property int pendingCount: notificationService ? notificationService.pendingModel.count : 0 - readonly property int pastCount: notificationService ? notificationService.pastModel.count : 0 - readonly property bool dnd: notificationService ? notificationService.doNotDisturb : false - - // Which tab is active in the popup. Auto-selects pending when there's - // something unseen; otherwise opens past. - property string activeTab: "pending" - - readonly property string icon: { - if (dnd) return "󰂛" - if (pendingCount > 0) return "󱅫" - return "󰂚" - } - - // Theme palette (mirrors HistoryPanel's tokens so the popup matches the - // rest of the notification stack). - readonly property color colForeground: Color.foreground - readonly property color colDim: Qt.darker(Color.foreground, 1.4) - readonly property color colBorder: Style.normalBorderFor(Color.foreground, Color.accent) - readonly property color colSurface: Style.normalFillFor(Color.foreground, Color.accent) - readonly property color colAccent: Color.accent - readonly property int cardRadius: notificationService ? notificationService.cornerRadius : 0 - - implicitWidth: button.implicitWidth - implicitHeight: button.implicitHeight - - WidgetButton { - id: button - anchors.fill: parent - bar: root.bar - text: root.icon - active: root.pendingCount > 0 && !root.dnd - tooltipText: root.dnd ? "Do Not Disturb" - : (root.pendingCount > 0 ? root.pendingCount + " pending" : "No notifications") - - onPressed: function(b) { - if (b === Qt.RightButton) { - if (root.notificationService) { - root.notificationService.setDoNotDisturb(!root.notificationService.doNotDisturb) - } - } else { - root.popupOpen = !root.popupOpen - } - } - } - - PopupCard { - id: popup - anchorItem: button - bar: root.bar - owner: root - open: root.popupOpen - contentWidth: popup.fittedContentWidth(Style.space(440)) - contentHeight: popup.cappedContentHeight(Style.space(540)) - - ColumnLayout { - anchors.fill: parent - spacing: Style.space(10) - - // ----------------------------------------- header - RowLayout { - Layout.fillWidth: true - spacing: Style.space(8) - - Text { - text: "Notifications" - font.family: root.bar ? root.bar.fontFamily : "" - color: root.colForeground - font.pixelSize: Style.font.title - font.bold: true - } - - Item { Layout.fillWidth: true } - - BorderSurface { - id: dndPill - Layout.preferredHeight: Math.max(Style.space(24), Style.font.bodySmall + Style.spacing.controlPaddingY * 2) - Layout.preferredWidth: dndLabel.implicitWidth + dndGlyph.implicitWidth + Style.space(18) - radius: Math.min(Style.space(12), root.cardRadius + Style.space(6)) - color: dndOn ? root.colAccent : root.colSurface - borderSpec: Border.flat(dndOn ? root.colAccent : root.colBorder, Style.normalBorderWidth) - - readonly property bool dndOn: !!root.notificationService && root.notificationService.doNotDisturb - - Row { - anchors.centerIn: parent - spacing: Style.space(4) - - Text { - id: dndGlyph - text: dndPill.dndOn ? "󰂛" : "󰂚" - font.family: root.bar ? root.bar.fontFamily : "" - color: dndPill.dndOn ? Color.background : root.colDim - font.pixelSize: Style.font.body - anchors.verticalCenter: parent.verticalCenter - } - - Text { - id: dndLabel - text: dndPill.dndOn ? "DND on" : "DND off" - font.family: root.bar ? root.bar.fontFamily : "" - color: dndPill.dndOn ? Color.background : root.colDim - font.pixelSize: Style.font.caption - font.bold: true - anchors.verticalCenter: parent.verticalCenter - } - } - - MouseArea { - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: if (root.notificationService) root.notificationService.setDoNotDisturb(!dndPill.dndOn) - } - } - } - - // ----------------------------------------- tabs - RowLayout { - Layout.fillWidth: true - spacing: 0 - - Repeater { - model: [ - { key: "pending", label: "Pending", count: root.pendingCount }, - { key: "past", label: "Recently", count: root.pastCount } - ] - delegate: Rectangle { - required property var modelData - readonly property bool isActive: root.activeTab === modelData.key - - Layout.fillWidth: true - Layout.preferredHeight: Math.max(Style.space(30), Style.font.body + Style.spacing.controlPaddingY * 2) - color: "transparent" - - Text { - anchors.centerIn: parent - text: modelData.label + (modelData.count > 0 ? " " + modelData.count : "") - font.family: root.bar ? root.bar.fontFamily : "" - color: parent.isActive ? root.colForeground : root.colDim - font.pixelSize: Style.font.body - font.bold: parent.isActive - } - - Rectangle { - anchors.left: parent.left - anchors.right: parent.right - anchors.bottom: parent.bottom - height: Math.max(1, Style.space(2)) - color: parent.isActive ? root.colAccent : root.colBorder - opacity: parent.isActive ? 1 : 0.4 - } - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: root.activeTab = modelData.key - } - } - } - } - - // ----------------------------------------- action row - RowLayout { - Layout.fillWidth: true - visible: (root.activeTab === "pending" && root.pendingCount > 0) - || (root.activeTab === "past" && root.pastCount > 0) - spacing: Style.space(8) - - Item { Layout.fillWidth: true } - - BorderSurface { - Layout.preferredWidth: actionLabel.implicitWidth + Style.space(16) - Layout.preferredHeight: Math.max(Style.space(22), Style.font.bodySmall + Style.spacing.controlPaddingY * 2) - radius: Math.min(Style.space(6), root.cardRadius) - color: actionArea.containsMouse ? root.colBorder : "transparent" - borderSpec: Border.flat(root.colBorder, Style.normalBorderWidth) - - Text { - id: actionLabel - anchors.centerIn: parent - text: root.activeTab === "pending" ? "Mark all as seen" : "Clear recent" - font.family: root.bar ? root.bar.fontFamily : "" - color: root.colForeground - font.pixelSize: Style.font.caption - } - - MouseArea { - id: actionArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (!root.notificationService) return - if (root.activeTab === "pending") root.notificationService.markAllSeen() - else root.notificationService.clearPast() - } - } - } - } - - // ----------------------------------------- list - ListView { - id: listView - Layout.fillWidth: true - Layout.fillHeight: true - clip: true - spacing: Style.space(8) - - readonly property bool onPending: root.activeTab === "pending" - model: !root.notificationService ? null - : (onPending ? root.notificationService.pendingModel : root.notificationService.pastModel) - visible: count > 0 - - delegate: BorderSurface { - id: rowCard - required property int index - required property string app - required property string appIcon - required property string summary - required property string body - required property string image - required property int urgency - required property double timestamp - - readonly property bool hasMedia: image.length > 0 && ( - image.indexOf("image://icon//") === 0 || image.indexOf("file://") === 0) - readonly property string smallIconSource: image.length > 0 ? image : root.notificationIconSource(appIcon) - readonly property bool hasIcon: !hasMedia && smallIconSource.length > 0 - readonly property string sanitizedBody: root.sanitizeBody(body, app, appIcon) - - width: listView.width - implicitHeight: rowContent.implicitHeight + Style.spacing.panelGap - radius: root.cardRadius - color: "transparent" - borderSpec: Border.flat(root.colBorder, Style.normalBorderWidth) - - MouseArea { - anchors.fill: parent - cursorShape: Qt.PointingHandCursor - onClicked: { /* no-op */ } - } - - RowLayout { - id: rowContent - anchors.left: parent.left - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - anchors.leftMargin: rowCard.borderLeft + Style.space(12) - anchors.rightMargin: rowCard.borderRight + Style.space(12) - spacing: Style.space(10) - - Item { - id: imageSlot - Layout.preferredWidth: Style.space(32) - Layout.preferredHeight: Style.space(32) - Layout.alignment: Qt.AlignVCenter - // Hide on icon load failure so unresolved themed-icon names - // don't render Qt's broken-image placeholder. - visible: (rowCard.hasIcon || rowCard.hasMedia) && rowIconImage.status !== Image.Error - - Image { - id: rowIconImage - anchors.fill: parent - source: rowCard.hasMedia ? rowCard.image : rowCard.smallIconSource - fillMode: rowCard.hasMedia ? Image.PreserveAspectCrop : Image.PreserveAspectFit - sourceSize.width: imageSlot.width * Screen.devicePixelRatio - sourceSize.height: imageSlot.height * Screen.devicePixelRatio - asynchronous: true - smooth: true - } - } - - ColumnLayout { - Layout.fillWidth: true - spacing: Style.space(2) - - Text { - Layout.fillWidth: true - visible: rowCard.summary.length > 0 - text: rowCard.summary - font.family: root.bar ? root.bar.fontFamily : "" - color: root.colForeground - font.pixelSize: Style.font.subtitle - font.bold: true - wrapMode: Text.WordWrap - elide: Text.ElideRight - maximumLineCount: 1 - } - - Text { - Layout.fillWidth: true - visible: rowCard.sanitizedBody.length > 0 - text: rowCard.sanitizedBody - font.family: root.bar ? root.bar.fontFamily : "" - textFormat: Text.PlainText - color: root.colDim - font.pixelSize: Style.font.bodySmall - wrapMode: Text.WordWrap - elide: Text.ElideRight - maximumLineCount: 2 - } - } - - Rectangle { - Layout.preferredWidth: Style.space(18) - Layout.preferredHeight: Style.space(18) - Layout.alignment: Qt.AlignVCenter - radius: Math.min(4, root.cardRadius) - color: rowCloseArea.containsMouse ? root.colBorder : "transparent" - - Text { - anchors.centerIn: parent - text: "✕" - font.family: root.bar ? root.bar.fontFamily : "" - color: root.colDim - font.pixelSize: Style.font.bodySmall - } - - MouseArea { - id: rowCloseArea - anchors.fill: parent - hoverEnabled: true - cursorShape: Qt.PointingHandCursor - onClicked: { - if (!root.notificationService) return - if (listView.onPending) root.notificationService.dismissPending(rowCard.index) - else root.notificationService.dismissPast(rowCard.index) - } - } - } - } - } - } - - // ----------------------------------------- empty state - Item { - Layout.fillWidth: true - Layout.fillHeight: true - visible: listView.count === 0 - - ColumnLayout { - anchors.centerIn: parent - spacing: Style.space(6) - - Text { - Layout.alignment: Qt.AlignHCenter - text: "󰂚" - font.family: root.bar ? root.bar.fontFamily : "" - color: root.colBorder - font.pixelSize: Style.font.displayLarge - } - - Text { - Layout.alignment: Qt.AlignHCenter - text: root.activeTab === "pending" - ? "Nothing waiting for you" - : "Nothing recent" - font.family: root.bar ? root.bar.fontFamily : "" - color: root.colDim - font.pixelSize: Style.font.body - } - } - } - } - } -} diff --git a/shell/plugins/notifications/Service.qml b/shell/plugins/notifications/Service.qml index a2f0fcea..c12d5eb7 100644 --- a/shell/plugins/notifications/Service.qml +++ b/shell/plugins/notifications/Service.qml @@ -331,7 +331,23 @@ Item { service.historyReplayLimit, NotificationUrgency.Normal) - if (rows.length === 0) return "none" + // Replaying nothing at all looks like a dead keybinding, so say so. + if (rows.length === 0) { + popupModel.insert(0, { + id: -1, + originalId: -1, + app: "omarchy-action", + appIcon: "", + summary: "No recent notifications", + body: "", + image: "", + glyph: "󰂚", + urgency: NotificationUrgency.Low, + expireTimeout: 0, + timestamp: Date.now() + }) + return "none" + } clearPopups() for (var i = 0; i < rows.length; i++) { @@ -787,26 +803,22 @@ Item { readonly property var popupPlacement: NotificationLogic.popupPlacement( service.barPosition, service.barClearance, Style.gapsOut) - anchors { - top: popupWindow.popupPlacement.anchors.top - bottom: popupWindow.popupPlacement.anchors.bottom - left: popupWindow.popupPlacement.anchors.left - right: popupWindow.popupPlacement.anchors.right - } - margins { - top: popupWindow.popupPlacement.margins.top - bottom: popupWindow.popupPlacement.margins.bottom - left: popupWindow.popupPlacement.margins.left - right: popupWindow.popupPlacement.margins.right - } + // Full-screen, fixed-size surface (like the OSD overlay). Adding or + // removing a toast changes only the content inside; the Wayland surface + // never resizes, so the compositor can't briefly scale a stale buffer -- + // which is what stretched/squished the cards during count changes. + anchors { top: true; bottom: true; left: true; right: true } - implicitWidth: popupColumn.implicitWidth - implicitHeight: popupColumn.implicitHeight + // Keep the surface click-through except over the toast column, so the + // rest of the (invisible) full-screen overlay never eats input. + mask: Region { item: popupColumn } ColumnLayout { id: popupColumn anchors.right: parent.right anchors.top: parent.top + anchors.topMargin: popupWindow.popupPlacement.margins.top + anchors.rightMargin: popupWindow.popupPlacement.margins.right spacing: Style.space(8) Repeater { diff --git a/shell/plugins/notifications/manifest.json b/shell/plugins/notifications/manifest.json index 404f6864..be71e3c2 100644 --- a/shell/plugins/notifications/manifest.json +++ b/shell/plugins/notifications/manifest.json @@ -4,20 +4,12 @@ "name": "Notifications", "version": "1.0.0", "author": "Omarchy", - "description": "Notification daemon, popups, and history", + "description": "Notification daemon, popups, DND, and history", "kinds": [ - "service", - "bar-widget" + "service" ], "keepLoaded": true, "entryPoints": { - "service": "Service.qml", - "barWidget": "BarWidget.qml" - }, - "barWidget": { - "displayName": "Notification center", - "description": "Recent notifications + DND", - "category": "Status", - "allowMultiple": false + "service": "Service.qml" } } diff --git a/shell/plugins/osd/Osd.qml b/shell/plugins/osd/Osd.qml index 2cd33bef..39f32e2f 100644 --- a/shell/plugins/osd/Osd.qml +++ b/shell/plugins/osd/Osd.qml @@ -17,24 +17,45 @@ Item { property int maxValue: 100 property bool hasProgress: true property int duration: 1200 - property bool fit: false - readonly property int cardWidth: Style.space(269) - readonly property int mediaCardWidth: Math.round(cardWidth * 1.5) - readonly property int messageWidth: Style.space(190) - readonly property int mediaMessageWidth: messageWidth + mediaCardWidth - cardWidth + readonly property bool mediaOsd: iconKey.indexOf("media") === 0 || iconKey.indexOf("player") === 0 - readonly property bool textOnlyOsd: root.fit && !root.hasProgress && !root.mediaOsd - readonly property int fitMessageWidth: root.message === "" ? 0 : Math.round(messageMetrics.boundingRect.width) + Style.space(4) - readonly property int fitCardWidth: root.message === "" || root.fitMessageWidth === 0 - ? card.borderLeft + Style.space(16) + Style.space(28) + Style.space(16) + card.borderRight - : card.borderLeft + Style.space(16) + Style.space(28) + Style.space(16) + root.fitMessageWidth + Style.space(16) + card.borderRight + + // The card is built out of measured columns instead of fixed widths, so it + // keeps exactly `pad` between border and content on every side whatever + // glyph or message it carries. Messages grow with their text up to + // `maxMessageWidth` and elide beyond it. + readonly property int pad: Style.space(16) + readonly property int gap: Style.space(16) + // A glyph next to a message reads airier than it measures: the icon outline + // and the letterforms both fall away from their ink extremes, so the space + // between them opens up well past the nominal gap. Text takes two thirds of + // it; the progress bar's hard edge keeps the full gap. + readonly property int messageGap: Math.round(root.gap * 2 / 3) + readonly property int barWidth: Style.space(142) + readonly property int maxMessageWidth: root.mediaOsd ? Style.space(325) : Style.space(190) + + // Nerd Font glyphs draw well outside their monospace cell, so the icon + // column is measured by ink rather than by advance width. Progress OSDs pin + // it to the widest glyph the model can return, so the bar doesn't shift when + // volume crosses an icon threshold. + readonly property int iconInkWidth: Math.ceil(iconMetrics.tightBoundingRect.width) + readonly property int iconWidth: root.hasProgress + ? Math.max(root.iconInkWidth, Math.ceil(widestIconMetrics.tightBoundingRect.width)) + : root.iconInkWidth + // Same idea for the readout: it is as wide as the longest percentage so the + // digits don't jitter between 9% and 100%. + readonly property int valueWidth: Math.ceil(Math.max(valueMetrics.advanceWidth, messageMetrics.advanceWidth)) + readonly property int messageWidth: Math.min(Math.ceil(messageMetrics.advanceWidth), root.maxMessageWidth) + readonly property int contentWidth: root.hasProgress + ? root.iconWidth + root.gap + root.barWidth + root.gap + root.valueWidth + : (root.message === "" ? root.iconWidth : root.iconWidth + root.messageGap + root.messageWidth) function iconFor(name, percent) { return OsdModel.iconFor(name, percent) } - function show(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration, rawFit) { - var next = OsdModel.stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration, rawFit) + function show(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration) { + var next = OsdModel.stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration) iconKey = next.iconKey maxValue = next.maxValue hasProgress = next.hasProgress @@ -42,7 +63,6 @@ Item { message = next.message icon = next.icon duration = next.duration - fit = next.fit opened = true if (duration > 0) hideTimer.restart() else hideTimer.stop() @@ -51,7 +71,7 @@ Item { function open(payloadJson) { try { var p = JSON.parse(payloadJson || "{}") - show(p.icon || "", p.message || "", p.value === undefined ? "" : String(p.value), p.max === undefined ? "100" : String(p.max), p.progressText || "", p.duration === undefined ? "1200" : String(p.duration), p.fit === undefined ? false : p.fit) + show(p.icon || "", p.message || "", p.value === undefined ? "" : String(p.value), p.max === undefined ? "100" : String(p.max), p.progressText || "", p.duration === undefined ? "1200" : String(p.duration)) } catch (e) {} } @@ -71,6 +91,25 @@ Item { text: root.message } + TextMetrics { + id: valueMetrics + font: messageMetrics.font + text: "100%" + } + + TextMetrics { + id: iconMetrics + font.family: Style.font.family + font.pixelSize: Style.font.displayLarge + text: root.icon + } + + TextMetrics { + id: widestIconMetrics + font: iconMetrics.font + text: OsdModel.widestIcon + } + IpcHandler { target: "osd" function show(payloadJson: string): string { @@ -97,8 +136,8 @@ Item { BorderSurface { id: card - width: root.textOnlyOsd ? root.fitCardWidth : (root.mediaOsd ? root.mediaCardWidth : root.cardWidth) - height: Math.max(Style.space(68), Style.font.displayLarge + Style.spacing.panelGap) + width: card.borderLeft + root.pad + root.contentWidth + root.pad + card.borderRight + height: card.borderTop + root.pad + Style.font.displayLarge + root.pad + card.borderBottom anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom anchors.bottomMargin: Style.space(67) @@ -109,23 +148,27 @@ Item { Row { anchors.fill: parent - anchors.topMargin: card.borderTop - anchors.rightMargin: card.borderRight + Style.space(16) - anchors.bottomMargin: card.borderBottom - anchors.leftMargin: card.borderLeft + Style.space(16) - spacing: Style.space(16) - Text { - width: Style.space(28) - anchors.verticalCenter: parent.verticalCenter - horizontalAlignment: Text.AlignHCenter - text: root.icon - font.family: Style.font.family - font.pixelSize: Style.font.displayLarge - color: Color.popups.text + anchors.topMargin: card.borderTop + root.pad + anchors.rightMargin: card.borderRight + root.pad + anchors.bottomMargin: card.borderBottom + root.pad + anchors.leftMargin: card.borderLeft + root.pad + spacing: root.hasProgress ? root.gap : root.messageGap + Item { + width: root.iconWidth + height: parent.height + Text { + // Sit the glyph's ink flush in the column, centered when the + // column is wider than this particular glyph. + x: Math.round((root.iconWidth - root.iconInkWidth) / 2 - iconMetrics.tightBoundingRect.x) + anchors.verticalCenter: parent.verticalCenter + text: root.icon + font: iconMetrics.font + color: Color.popups.text + } } Rectangle { visible: root.hasProgress - width: visible ? Style.space(142) : 0 + width: root.barWidth height: Math.max(Style.space(6), Style.spacing.sm) anchors.verticalCenter: parent.verticalCenter color: Util.alpha(Color.popups.text, 0.45) @@ -136,16 +179,17 @@ Item { } } Text { - width: root.textOnlyOsd ? root.fitMessageWidth : (root.hasProgress ? Style.space(41) : (root.mediaOsd ? root.mediaMessageWidth : root.messageWidth)) + visible: root.message !== "" + width: root.hasProgress ? root.valueWidth : root.messageWidth + // The readout hugs the card edge so a short percentage doesn't leave + // a hole in the padding; the slack lands in the gap after the bar. + horizontalAlignment: root.hasProgress ? Text.AlignRight : Text.AlignLeft anchors.verticalCenter: parent.verticalCenter text: root.message - font.family: Style.font.family - font.bold: true - font.pixelSize: Style.font.title + font: messageMetrics.font color: Color.popups.text - elide: root.textOnlyOsd ? Text.ElideNone : Text.ElideRight + elide: Text.ElideRight maximumLineCount: 1 - clip: !root.textOnlyOsd } } } diff --git a/shell/plugins/osd/OsdModel.js b/shell/plugins/osd/OsdModel.js index fda2eede..a29ca81b 100644 --- a/shell/plugins/osd/OsdModel.js +++ b/shell/plugins/osd/OsdModel.js @@ -2,6 +2,10 @@ function clamp(value, min, max) { return Math.max(min, Math.min(max, value)) } +// The widest glyph `iconFor` can return. The progress OSD sizes its icon +// column to it so the bar keeps its place as the icon changes. +var widestIcon = "" + function iconFor(name, percent) { var n = String(name || "").toLowerCase() if (n === "volume-muted" || n === "volume-mute" || n === "muted" || n === "mute") return "" @@ -30,14 +34,13 @@ function iconFor(name, percent) { return "" } -function stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration, rawFit) { +function stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, rawDuration) { var maxValue = Math.max(1, parseInt(rawMax || "100", 10)) var parsedValue = parseInt(rawValue || "0", 10) var hasProgress = rawValue !== "" && !isNaN(parsedValue) && rawMessage === "" var value = hasProgress ? clamp(parsedValue, 0, maxValue) : 0 var percent = hasProgress ? Math.round(value * 100 / maxValue) : -1 var parsedDuration = parseInt(rawDuration || "1200", 10) - var fit = rawFit === true || rawFit === 1 || rawFit === "1" || rawFit === "true" return { iconKey: String(iconName || "").toLowerCase(), @@ -46,13 +49,13 @@ function stateForShow(iconName, rawMessage, rawValue, rawMax, rawProgressText, r value: value, message: String(rawMessage || (hasProgress ? (rawProgressText || percent + "%") : "")), icon: iconFor(iconName, percent), - duration: isNaN(parsedDuration) ? 1200 : Math.max(0, parsedDuration), - fit: fit + duration: isNaN(parsedDuration) ? 1200 : Math.max(0, parsedDuration) } } if (typeof module !== "undefined") { module.exports = { + widestIcon: widestIcon, iconFor: iconFor, stateForShow: stateForShow } diff --git a/shell/plugins/panels/audio/Panel.qml b/shell/plugins/panels/audio/Panel.qml index 5a29b37c..a47fdf61 100644 --- a/shell/plugins/panels/audio/Panel.qml +++ b/shell/plugins/panels/audio/Panel.qml @@ -46,7 +46,11 @@ Panel { var list = [] for (var i = 0; i < nodes.length; i++) { var n = nodes[i] - if (n && n.isStream && isPlaybackStream(n)) list.push(n) + if (!n || !n.isStream || !isPlaybackStream(n)) continue + // A tuning's output is a playback stream too, but it is the processing + // itself rather than an application, so it does not belong in the list. + if (String(n.name || "").indexOf("omarchy_speaker_tuning") === 0) continue + list.push(n) } return list } @@ -105,8 +109,39 @@ Panel { property var displayAudioSources: [] property var displayAudioStreams: [] - readonly property real outputVolume: sink && sink.audio ? sink.audio.volume : 0 - readonly property bool outputMuted: sink && sink.audio ? sink.audio.muted : false + // A DSP sink -- a speaker tuning, or EasyEffects -- can be the selected output + // without being where loudness lives: changing its volume alters the level going + // *into* the processing, so the slider would move while the speakers did not, + // and on a chain with a limiter it would change the tone as well. + // + // omarchy-audio-output-sink resolves the *current* default output through any + // such sink to the physical one, which is the same definition the volume keys + // and the output switcher use. Resolving the default (rather than "whatever a + // tuning fronts") is what keeps this correct when headphones or HDMI are + // selected while a tuning still exists. + property string volumeSinkName: "" + + readonly property var volumeSink: { + if (volumeSinkName === "" || !sink) return sink + if (volumeSinkName === String(sink.name)) return sink + for (var i = 0; i < nodes.length; i++) { + var n = nodes[i] + if (n && n.isSink && !n.isStream && String(n.name) === volumeSinkName && n.audio) + return n + } + return sink + } + + // Re-resolve whenever the selected output changes; the timer below is only a + // safety net for the tuning being applied or removed underneath us. + onSinkChanged: resolveVolumeSink() + + function resolveVolumeSink() { + if (!volumeSinkProc.running) volumeSinkProc.running = true + } + + readonly property real outputVolume: volumeSink && volumeSink.audio ? volumeSink.audio.volume : 0 + readonly property bool outputMuted: volumeSink && volumeSink.audio ? volumeSink.audio.muted : false readonly property real inputVolume: source && source.audio ? source.audio.volume : 0 readonly property bool inputMuted: source && source.audio ? source.audio.muted : false @@ -377,7 +412,7 @@ Panel { function setOutputVolume(v) { if (!sink || !sink.audio) return - sink.audio.volume = Math.max(0, Math.min(1, v)) + volumeSink.audio.volume = Math.max(0, Math.min(1, v)) } function setInputVolume(v) { @@ -386,7 +421,7 @@ Panel { } function toggleOutputMute() { - if (sink && sink.audio) sink.audio.muted = !sink.audio.muted + if (volumeSink && volumeSink.audio) volumeSink.audio.muted = !volumeSink.audio.muted } function toggleInputMute() { @@ -525,6 +560,15 @@ Panel { } } + Process { + id: volumeSinkProc + command: ["omarchy-audio-output-sink"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.volumeSinkName = String(text).trim() + } + } + Timer { interval: 5000 running: root.opened @@ -533,6 +577,17 @@ Panel { onTriggered: if (!sinkAvailabilityProc.running) sinkAvailabilityProc.running = true } + // Runs whether or not the panel is open: the bar shows and scrolls the output + // volume too, so an unresolved sink there would read and change the virtual + // tuning sink instead of the speakers. + Timer { + interval: 15000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: root.resolveVolumeSink() + } + Timer { id: audioModelRefreshTimer interval: 75 diff --git a/shell/plugins/panels/bluetooth/Panel.qml b/shell/plugins/panels/bluetooth/Panel.qml index d6b4cf5d..c769b519 100644 --- a/shell/plugins/panels/bluetooth/Panel.qml +++ b/shell/plugins/panels/bluetooth/Panel.qml @@ -502,7 +502,6 @@ Panel { text: root.icon onPressed: function(b) { if (b === Qt.RightButton) root.toggleBluetooth() - else if (b === Qt.MiddleButton) root.bar.run("omarchy-launch-bluetooth") else root.toggle() } } diff --git a/shell/plugins/panels/monitor/Model.js b/shell/plugins/panels/monitor/Model.js index afe3efca..215972b9 100644 --- a/shell/plugins/panels/monitor/Model.js +++ b/shell/plugins/panels/monitor/Model.js @@ -10,6 +10,75 @@ function normalizeScale(scale) { return String(Math.round(n * 100) / 100) } +function gcd(a, b) { + while (b) { + var remainder = a % b + a = b + b = remainder + } + return a +} + +function cleanScale(scale, width, height) { + var requested = Number(scale) + var modeWidth = Number(width) + var modeHeight = Number(height) + if (!isFinite(requested) || !isFinite(modeWidth) || !isFinite(modeHeight) + || requested <= 0 || modeWidth <= 0 || modeHeight <= 0) return "" + + var divisor = gcd(Math.round(modeWidth * 120), Math.round(modeHeight * 120)) + var scaleUnits = Math.round(requested * 120) + if (scaleUnits > divisor) scaleUnits = divisor + while (divisor % scaleUnits !== 0) scaleUnits++ + return normalizeScale(scaleUnits / 120) +} + +function matchingScaleIndex(scales, currentScale, width, height) { + var current = Number(currentScale) + if (!Array.isArray(scales) || !isFinite(current)) return -1 + + var bestIndex = -1 + var bestDistance = Infinity + var normalizedCurrent = normalizeScale(current) + for (var i = 0; i < scales.length; i++) { + if (cleanScale(scales[i], width, height) !== normalizedCurrent) continue + + var distance = Math.abs(Number(scales[i]) - current) + if (distance < bestDistance) { + bestIndex = i + bestDistance = distance + } + } + return bestIndex +} + +function availableScales(scales, width, height) { + if (!Array.isArray(scales) || Number(width) <= 0 || Number(height) <= 0) return scales || [] + + var byEffectiveScale = {} + for (var i = 0; i < scales.length; i++) { + var requested = Number(scales[i]) + var effective = Number(cleanScale(requested, width, height)) + + if (!isFinite(requested) || !isFinite(effective)) continue + + var key = normalizeScale(effective) + var existing = byEffectiveScale[key] + if (!existing || Math.abs(requested - effective) < existing.distance) { + byEffectiveScale[key] = { + value: String(scales[i]), + index: i, + distance: Math.abs(requested - effective) + } + } + } + + return Object.keys(byEffectiveScale) + .map(function(key) { return byEffectiveScale[key] }) + .sort(function(a, b) { return a.index - b.index }) + .map(function(candidate) { return candidate.value }) +} + function brightnessName(percent) { var p = Math.round(percent) if (p >= 95) return "Sun blast" @@ -46,6 +115,9 @@ if (typeof module !== "undefined") { module.exports = { clampBrightness: clampBrightness, normalizeScale: normalizeScale, + cleanScale: cleanScale, + matchingScaleIndex: matchingScaleIndex, + availableScales: availableScales, brightnessName: brightnessName, parseDisplays: parseDisplays } diff --git a/shell/plugins/panels/monitor/Panel.qml b/shell/plugins/panels/monitor/Panel.qml index 84117c86..d822629c 100644 --- a/shell/plugins/panels/monitor/Panel.qml +++ b/shell/plugins/panels/monitor/Panel.qml @@ -38,7 +38,15 @@ Panel { // j/k walks each row. // Mouse hover on a target updates root state via the components' `hovered` // signal so keyboard cursor and pointer share one highlight. - readonly property var scaleValues: ["1", "1.25", "1.6", "2", "3", "4"] + readonly property var scalePresets: ["1", "1.25", "1.6", "2", "3", "4"] + readonly property var scaleValues: { + for (var i = 0; i < displays.length; i++) { + var display = displays[i] + if (display && display.focused) + return Model.availableScales(scalePresets, display.width, display.height) + } + return scalePresets + } property string focusSection: "scale" property int selectedIndex: 0 property bool cursorActive: false @@ -246,6 +254,24 @@ Panel { return Model.normalizeScale(scale) } + function activeScaleIndex() { + for (var i = 0; i < displays.length; i++) { + var display = displays[i] + if (display && display.focused) + return Model.matchingScaleIndex(scaleValues, monitorScale, display.width, display.height) + } + return -1 + } + + function effectiveScale(scale) { + for (var i = 0; i < displays.length; i++) { + var display = displays[i] + if (display && display.focused) + return Model.cleanScale(scale, display.width, display.height) + } + return normalizeScale(scale) + } + // Playful mood-name for a given brightness percent. Bands intentionally // span ~10–20 points so casual tweaks change the label, while small // nudges within one band don't. @@ -333,6 +359,7 @@ Panel { onBrightnessAvailableChanged: clampCursor() onDisplaysChanged: clampCursor() + onScaleValuesChanged: clampCursor() onVisibleSectionsChanged: clampCursor() // Only poll while the panel is open; the bar glyph tracks monitor count via @@ -520,8 +547,7 @@ Panel { if (root.brightnessAvailable) { return root.brightnessName(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent).toUpperCase() } - var count = root.enabledDisplayCount - return (count === 1 ? "1 display" : count + " displays").toUpperCase() + return "FIXED BRIGHTNESS" } color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily @@ -688,10 +714,34 @@ Panel { width: parent.width spacing: Style.space(10) - PanelSectionHeader { - text: "SCALE" - foreground: root.bar.foreground - fontFamily: root.bar.fontFamily + Item { + width: parent.width + implicitHeight: Math.max(scaleHeader.implicitHeight, scaleMonitor.implicitHeight) + + PanelSectionHeader { + id: scaleHeader + text: "SCALE" + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + } + + // Name the monitor SCALE targets, since it only applies to the + // focused one. + Text { + id: scaleMonitor + text: root.focusedMonitor + // Only worth naming when more than one display is in play. + visible: root.focusedMonitor !== "" && root.enabledDisplayCount > 1 + color: Qt.darker(root.bar.foreground, 1.4) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + anchors.right: parent.right + anchors.rightMargin: Style.space(6) + anchors.verticalCenter: parent.verticalCenter + } } Grid { @@ -764,7 +814,7 @@ Panel { required property string scaleValue required property int scaleIndex - text: scaleValue + "x" + text: root.effectiveScale(scaleValue) + "x" fontSize: Style.font.caption foreground: root.bar.foreground fontFamily: root.bar.fontFamily @@ -772,7 +822,7 @@ Panel { verticalPadding: Style.spacing.controlPaddingY bordered: true - active: root.normalizeScale(root.monitorScale) === root.normalizeScale(scaleValue) + active: root.activeScaleIndex() === scaleIndex hasCursor: root.cursorActive && root.focusSection === "scale" && root.selectedIndex === scaleIndex onClicked: root.setScale(scaleValue) diff --git a/shell/plugins/panels/network/Model.js b/shell/plugins/panels/network/Model.js index a3bd0b41..c16b24ed 100644 --- a/shell/plugins/panels/network/Model.js +++ b/shell/plugins/panels/network/Model.js @@ -232,6 +232,19 @@ function isProtected(security, openSecurity) { return security !== openSecurity } +// The password arrives on stdin and reaches nmcli through the scriptable +// `connection edit` editor -- argv is world-readable in /proc, so the secret +// must never be an argument (printf is a bash builtin, so no process spawns +// with it either). +var enterpriseConnectScript = + "u=$(uuidgen); IFS= read -r pw;" + + " nmcli connection add type wifi con-name \"$1\" ssid \"$1\" connection.uuid \"$u\"" + + " wifi-sec.key-mgmt wpa-eap 802-1x.eap peap 802-1x.phase2-auth mschapv2" + + " 802-1x.identity \"$2\" 802-1x.auth-timeout 8 >/dev/null" + + " && printf 'set 802-1x.password %s\\nsave\\nquit\\n' \"$pw\" | nmcli connection edit uuid \"$u\" >/dev/null" + + " && nmcli connection up uuid \"$u\"" + + " || { nmcli connection delete uuid \"$u\" >/dev/null 2>&1; false; }" + function networkFailureReason(reason, reasons) { var r = reasons || {} if (reason === r.NoSecrets) return "Passphrase required" @@ -263,6 +276,7 @@ if (typeof module !== "undefined") { sortWifiRows: sortWifiRows, wifiSectionTitle: wifiSectionTitle, isProtected: isProtected, + enterpriseConnectScript: enterpriseConnectScript, networkFailureReason: networkFailureReason } } diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index 3c4a48f9..935d1673 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -16,7 +16,13 @@ Panel { // Centralized close so callers can't forget to drop the passphrase prompt. function close() { root.controller.hide() + cancelPasswordPrompt() + } + + function cancelPasswordPrompt() { passwordSsid = "" + passwordText = "" + identityText = "" } // Live connection details from `ip` / /sys / iw. @@ -84,6 +90,7 @@ Panel { property string failureReason: "" property string passwordSsid: "" property string passwordText: "" + property string identityText: "" // True while any wifi action is mid-flight. Rows // disable themselves on this so clicks on the other rows don't silently @@ -489,7 +496,10 @@ Panel { } function openPasswordPrompt(ssid) { - if (passwordSsid !== ssid) passwordText = "" + if (passwordSsid !== ssid) { + passwordText = "" + identityText = "" + } passwordSsid = ssid } @@ -567,6 +577,26 @@ Panel { runNetworkAction("connect", networkForSsid(ssid), function(network) { network.connectWithPsk(passphrase) }) } + function connectEnterprise(ssid, identity, passphrase) { + runNetworkAction("connect", networkForSsid(ssid), function(network) { + enterpriseConnect.secret = passphrase + enterpriseConnect.command = ["bash", "-c", Model.enterpriseConnectScript, "nmcli-eap", ssid, identity] + enterpriseConnect.running = true + }) + } + + // Creates and activates the 802.1X profile (see Model.enterpriseConnectScript). + // The password goes over stdin, never argv. + Process { + id: enterpriseConnect + property string secret: "" + stdinEnabled: true + onStarted: { + write(secret + "\n") + secret = "" + } + } + function disconnect(network) { runNetworkAction("disconnect", network || connectedWifiNetwork, function(net) { net.disconnect() }) } @@ -1235,6 +1265,9 @@ Panel { readonly property bool isConnected: net && net.connected readonly property bool isKnown: !!(net && net.known) readonly property bool isProtected: net ? root.isProtected(net.security) : false + readonly property bool isEnterprise: net + ? (net.security === WifiSecurityType.Wpa2Eap || net.security === WifiSecurityType.WpaEap) + : false readonly property bool canForgetFromLock: isKnown && isProtected && !isConnected readonly property bool isSelected: root.focusSection === "wifi" && root.selectedIndex === index readonly property bool forgetFocused: isSelected && root.wifiActionFocused && canForgetFromLock @@ -1251,6 +1284,12 @@ Panel { readonly property bool isFailed: root.failureReason !== "" && root.failureSsid === (net ? net.ssid : "") readonly property bool isPasswordOpen: root.passwordSsid !== "" && root.passwordSsid === (net ? net.ssid : "") + function submitCredentials() { + if (!net || root.busy || root.passwordText.length === 0) return + if (!isEnterprise) return root.connectWithPassphrase(net.ssid, root.passwordText) + if (root.identityText.length > 0) root.connectEnterprise(net.ssid, root.identityText, root.passwordText) + } + Connections { target: row.net ? row.net.network : null function onConnectionFailed(reason) { @@ -1450,15 +1489,40 @@ Panel { anchors.leftMargin: Style.space(10) anchors.rightMargin: Style.space(10) anchors.topMargin: Style.space(4) - implicitHeight: pwField.implicitHeight + Style.spacing.rowGap + implicitHeight: (idField.visible ? idField.implicitHeight + Style.space(4) : 0) + pwField.implicitHeight + Style.spacing.rowGap height: implicitHeight + TextField { + id: idField + visible: row.isEnterprise && !row.isBusy && !row.isFailed + anchors.left: parent.left + anchors.right: connectPwBtn.left + anchors.top: parent.top + anchors.rightMargin: Style.space(6) + placeholderText: "Identity (user@domain)" + font.family: Style.font.family + font.pixelSize: Style.font.body + foreground: root.bar.foreground + horizontalPadding: Style.spacing.controlGap + verticalPadding: Style.spacing.controlPaddingY + enabled: !row.isBusy + text: row.isPasswordOpen ? root.identityText : "" + + onAccepted: pwField.forceActiveFocus() + onTextChanged: if (row.isPasswordOpen && text !== root.identityText) root.identityText = text + Keys.onEscapePressed: root.cancelPasswordPrompt() + + onVisibleChanged: if (visible) Qt.callLater(forceActiveFocus) + Component.onCompleted: if (visible) Qt.callLater(forceActiveFocus) + } + TextField { id: pwField visible: !row.isBusy && !row.isFailed anchors.left: parent.left anchors.right: connectPwBtn.left - anchors.verticalCenter: parent.verticalCenter + anchors.bottom: parent.bottom + anchors.bottomMargin: Style.spacing.rowGap / 2 anchors.rightMargin: Style.space(6) password: true placeholderText: "Passphrase" @@ -1470,14 +1534,12 @@ Panel { enabled: !row.isBusy text: row.isPasswordOpen ? root.passwordText : "" - onAccepted: { - if (!root.busy && row.net && text.length > 0) root.connectWithPassphrase(row.net.ssid, text) - } + onAccepted: row.submitCredentials() onTextChanged: if (row.isPasswordOpen && text !== root.passwordText) root.passwordText = text - Keys.onEscapePressed: { root.passwordSsid = ""; root.passwordText = "" } + Keys.onEscapePressed: root.cancelPasswordPrompt() - onVisibleChanged: if (visible) Qt.callLater(forceActiveFocus) - Component.onCompleted: if (visible) Qt.callLater(forceActiveFocus) + onVisibleChanged: if (visible && !row.isEnterprise) Qt.callLater(forceActiveFocus) + Component.onCompleted: if (visible && !row.isEnterprise) Qt.callLater(forceActiveFocus) } BorderSurface { @@ -1510,12 +1572,12 @@ Panel { visible: !row.isBusy && !row.isFailed anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter - enabled: row.net && pwField.text.length > 0 + enabled: row.net && pwField.text.length > 0 && (!row.isEnterprise || idField.text.length > 0) iconText: "󰄬" tooltipText: "Connect" foreground: root.bar.foreground fontFamily: root.bar.fontFamily - onClicked: if (row.net) root.connectWithPassphrase(row.net.ssid, root.passwordText) + onClicked: row.submitCredentials() } } } diff --git a/shell/plugins/polkit/PolkitAgent.qml b/shell/plugins/polkit/PolkitAgent.qml index a27ab019..8ce95973 100644 --- a/shell/plugins/polkit/PolkitAgent.qml +++ b/shell/plugins/polkit/PolkitAgent.qml @@ -32,24 +32,34 @@ Item { property bool responseVisible: false property bool failed: false property bool errorFlash: false - property bool fingerprintFirst: false + // pam_fprintd appears in the polkit PAM stack (a sensor is enrolled). + property bool fingerprintConfigured: false + // Lid shut right now — the reader is physically unreachable, so we fall back + // to the password even when a sensor is enrolled. Refreshed per request. + property bool laptopClosed: false property int shakeOffset: 0 readonly property bool dialogVisible: polkitAgent.isActive || closing - readonly property bool fingerprintWaiting: dialogVisible && !responseRequired && !submitted && (fingerprintFirst || promptLooksFingerprint(currentPrompt + " " + currentSupplementary)) - readonly property int cardWidth: Math.min(Style.space(312), Math.max(Style.space(260), panel.width - Style.gapsOut * 2)) + // We show one method at a time. Fingerprint owns the dialog while PAM is + // waiting on the reader (lid open, sensor enrolled); the moment PAM asks for + // a password — including immediately when the lid is shut and the clamshell + // gate skips pam_fprintd — we switch to the password field instead. + readonly property bool fingerprintMode: fingerprintConfigured && !laptopClosed && dialogVisible && !responseRequired && !submitted && !errorFlash readonly property int cardHeight: panel.height > 0 ? Math.min(fieldHeight + contentMargin * 2, panel.height - Style.gapsOut * 2) : fieldHeight + contentMargin * 2 - - function promptLooksFingerprint(text) { - return PolkitModel.promptLooksFingerprint(text) - } + // Password mode is a wide field; fingerprint mode collapses to a square that + // just frames the centered sensor icon. + readonly property int cardWidth: fingerprintMode ? cardHeight : Math.min(Style.space(312), Math.max(Style.space(260), panel.width - Style.gapsOut * 2)) function authorizationLabel(message) { return PolkitModel.authorizationLabel(message) } function loadPamConfig(raw) { - fingerprintFirst = PolkitModel.fingerprintFirstFromPamConfig(raw) + fingerprintConfigured = PolkitModel.fingerprintConfiguredFromPamConfig(raw) + } + + function refreshLidState() { + if (!laptopClosedProc.running) laptopClosedProc.running = true } function resetSnapshot() { @@ -83,13 +93,16 @@ Item { closing = false submitted = false passwordInput.text = "" + refreshLidState() syncFromFlow() Qt.callLater(refocus) } function refocus() { if (!dialogVisible) return - if (fingerprintWaiting) keyCatcher.forceActiveFocus() + // In fingerprint mode there is no field to type into — park focus on the + // key catcher so Escape still cancels; otherwise focus the password field. + if (fingerprintMode) keyCatcher.forceActiveFocus() else passwordInput.forceActiveFocus() } @@ -149,10 +162,17 @@ Item { watchChanges: true printErrors: false onLoaded: root.loadPamConfig(text()) - onLoadFailed: root.fingerprintFirst = false + onLoadFailed: root.fingerprintConfigured = false onFileChanged: reload() } + Process { + id: laptopClosedProc + command: ["bash", "-c", "omarchy-hw-laptop-closed && echo closed || echo open"] + stdout: StdioCollector { id: laptopClosedOut; waitForEnd: true } + onExited: root.laptopClosed = String(laptopClosedOut.text || "").trim() === "closed" + } + PolkitAgent { id: polkitAgent path: "/org/omarchy/PolkitAgent" @@ -248,8 +268,22 @@ Item { } } + // Fingerprint mode shows just the sensor icon, centered and alone \u2014 no + // padlock, no field, no prompt text. + OpticalGlyph { + anchors.centerIn: parent + width: Math.round(root.fieldHeight * 0.7) + height: width + visible: root.fingerprintMode + text: "\udb80\ude37" + fontFamily: root.fontFamily + fontSize: Math.round(root.fieldHeight * 0.7) + color: root.errorFlash ? Color.polkit.textError : root.accent + } + Row { id: cardRow + visible: !root.fingerprintMode anchors.fill: parent anchors.topMargin: card.contentTopInset anchors.rightMargin: card.contentRightInset @@ -287,8 +321,7 @@ Item { color: root.errorFlash ? Color.polkit.textError : root.foreground cursorVisible: activeFocus && !root.submitted && !root.errorFlash readOnly: root.submitted || root.errorFlash - enabled: root.dialogVisible && !root.fingerprintWaiting - visible: !root.fingerprintWaiting + enabled: root.dialogVisible onAccepted: root.submitResponse() Keys.onPressed: function(event) { if (event.key === Qt.Key_Escape) { @@ -308,7 +341,7 @@ Item { font.family: root.fontFamily font.pixelSize: Style.font.iconLarge elide: Text.ElideRight - visible: passwordInput.visible && passwordInput.text.length === 0 + visible: passwordInput.text.length === 0 } Rectangle { diff --git a/shell/plugins/polkit/PolkitModel.js b/shell/plugins/polkit/PolkitModel.js index ca2ea142..7ce71c78 100644 --- a/shell/plugins/polkit/PolkitModel.js +++ b/shell/plugins/polkit/PolkitModel.js @@ -3,13 +3,16 @@ function promptLooksFingerprint(text) { return s.indexOf("finger") !== -1 || s.indexOf("fprint") !== -1 || s.indexOf("swipe") !== -1 } -function fingerprintFirstFromPamConfig(raw) { +function fingerprintConfiguredFromPamConfig(raw) { + // Fingerprint is available whenever pam_fprintd appears anywhere in the auth + // stack — it need not be the first module. A clamshell gate (pam_exec) may + // legitimately precede it to skip fingerprint while the lid is closed. var lines = String(raw || "").split("\n") for (var i = 0; i < lines.length; i++) { var line = lines[i].replace(/^\s+|\s+$/g, "") if (!line || line.charAt(0) === "#") continue if (!line.match(/^auth\s+/)) continue - return line.indexOf("pam_fprintd.so") !== -1 + if (line.indexOf("pam_fprintd.so") !== -1) return true } return false } @@ -23,7 +26,7 @@ function authorizationLabel(message) { if (typeof module !== "undefined") { module.exports = { promptLooksFingerprint: promptLooksFingerprint, - fingerprintFirstFromPamConfig: fingerprintFirstFromPamConfig, + fingerprintConfiguredFromPamConfig: fingerprintConfiguredFromPamConfig, authorizationLabel: authorizationLabel } } diff --git a/shell/plugins/services/tmux/Service.qml b/shell/plugins/services/tmux/Service.qml new file mode 100644 index 00000000..da8efc04 --- /dev/null +++ b/shell/plugins/services/tmux/Service.qml @@ -0,0 +1,60 @@ +import QtQuick +import Quickshell.Io +import "TmuxModel.js" as TmuxModel + +Item { + id: root + + // Injected by omarchy-shell (the first-party service loader). + property var shell: null + + property int waitingCount: 0 + property string tooltip: "" + property bool refreshPending: false + + readonly property bool waiting: waitingCount > 0 + + // A bar surface exists per monitor and each one instantiates the indicator + // twice (active and inactive block), so the probe lives here instead: one + // process for the whole shell, and every indicator reads the same answer. + function refresh() { + if (statusProc.running) refreshPending = true + else statusProc.running = true + } + + // tmux hooks cover the flag transitions, but output in a selected window + // whose terminal sits on another workspace never fires one, and neither does + // a window or session killed while it was still flagged. + Timer { + interval: 3000 + running: true + repeat: true + onTriggered: root.refresh() + } + + Process { + id: statusProc + command: ["omarchy-tmux-alert", "show", "--json"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: { + var waiting = TmuxModel.waitingFromOutput(text) + root.waitingCount = waiting.count + root.tooltip = waiting.tooltip + } + } + onExited: function(exitCode) { + if (exitCode !== 0) { + root.waitingCount = 0 + root.tooltip = "" + } + + if (root.refreshPending) { + root.refreshPending = false + root.refresh() + } + } + } + + Component.onCompleted: refresh() +} diff --git a/shell/plugins/services/tmux/TmuxModel.js b/shell/plugins/services/tmux/TmuxModel.js new file mode 100644 index 00000000..1e93b2b1 --- /dev/null +++ b/shell/plugins/services/tmux/TmuxModel.js @@ -0,0 +1,29 @@ +// Shape of `omarchy-tmux-alert show --json`. The command emits one JSON object +// on the last line; anything tmux or a shell profile printed before it is +// ignored so a noisy environment cannot blank the indicator. +function waitingFromOutput(output) { + var text = String(output === undefined || output === null ? "" : output).trim() + if (!text) return { count: 0, tooltip: "" } + + var lines = text.split("\n") + var data + try { + data = JSON.parse(lines[lines.length - 1]) + } catch (e) { + return { count: 0, tooltip: "" } + } + + if (!data || typeof data !== "object") return { count: 0, tooltip: "" } + + var count = Number(data.count) + return { + count: isFinite(count) && count > 0 ? Math.floor(count) : 0, + tooltip: data.tooltip === undefined || data.tooltip === null ? "" : String(data.tooltip) + } +} + +if (typeof module !== "undefined") { + module.exports = { + waitingFromOutput: waitingFromOutput + } +} diff --git a/shell/plugins/services/tmux/manifest.json b/shell/plugins/services/tmux/manifest.json new file mode 100644 index 00000000..eeddb28d --- /dev/null +++ b/shell/plugins/services/tmux/manifest.json @@ -0,0 +1,14 @@ +{ + "schemaVersion": 1, + "id": "omarchy.tmux", + "name": "Tmux", + "version": "1.0.0", + "author": "Omarchy", + "description": "Owns the set of tmux windows waiting for attention for the bar indicator.", + "kinds": [ + "service" + ], + "entryPoints": { + "service": "Service.qml" + } +} diff --git a/shell/services/AppLibrary.qml b/shell/services/AppLibrary.qml new file mode 100644 index 00000000..35a3a65c --- /dev/null +++ b/shell/services/AppLibrary.qml @@ -0,0 +1,258 @@ +import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import qs.Commons +import "AppSearch.js" as AppSearch + +// Shared desktop-application library: the sorted entry list with hidden-entry +// filtering, the icon fallback index, launch feedback, and entry removal. +// Injected as shell.appLibrary; the menu's Apps submenu is the consumer. +Item { + id: root + + property string omarchyPath: Quickshell.env("OMARCHY_PATH") + + property var configuredHiddenEntryIds: ({}) + property var desktopHiddenEntryIds: ({}) + + // Maps an icon name to a file on disk (e.g. "omacut" -> ".../apps/omacut.svg"). + // Used as a fallback for icons that Qt's themed lookup misses because they were + // installed after this process started (its icon cache never re-scans). Refreshed + // whenever the app list changes, so newly installed apps get their icon live. + property var iconIndex: ({}) + property var pendingIconIndex: ({}) + + property int launchSerial: 0 + property int launchToplevelCount: 0 + property var launchActiveToplevel: null + property bool launchOsdOpen: false + property string launchOsdMessage: "" + + // Emitted whenever the visible application set may have changed: desktop + // entries appeared or vanished, or the hidden-entry filters reloaded. + signal appsChanged() + + function entryName(entry) { + return AppSearch.entryName(entry) + } + + function entrySubtext(entry) { + return AppSearch.entrySubtext(entry) + } + + function isHiddenEntry(entry) { + var id = String((entry && entry.id) || "") + return root.configuredHiddenEntryIds[id] === true || root.desktopHiddenEntryIds[id] === true + } + + function sortedEntries(query) { + var values = DesktopEntries.applications.values || [] + return AppSearch.sortedEntries(values, query, function(entry) { return root.isHiddenEntry(entry) }) + } + + function iconSource(icon) { + var value = String(icon || "") + if (value.length === 0) return Quickshell.iconPath("application-x-executable", true) + if (value.indexOf("file://") === 0 || value.indexOf("image://") === 0) return value + if (value.charAt(0) === "/") return Util.fileUrl(value) + // Prefer the context-limited app/device index. An unconstrained themed + // lookup can resolve an app name such as "zoom" to an action icon instead. + var found = root.iconIndex[value] + if (found) return Util.fileUrl(found) + var themed = Quickshell.iconPath(value, true) + if (themed.length > 0) return themed + return Quickshell.iconPath("application-x-executable", true) + } + + // The shell may start before first-install packages have finished placing + // their icons; consumers call this when they open so icons appear live. + function refreshIcons() { + if (!iconIndexScan.running) iconIndexScan.running = true + } + + function launch(desktopId, name) { + var id = String(desktopId || "") + if (!id) return + root.beginLaunchFeedback(name) + Util.execDetached("gtk-launch " + Util.shellQuote(id)) + } + + function remove(desktopId, name) { + var id = String(desktopId || "") + if (!id) return + Util.execDetached(Util.shellQuote(root.omarchyPath + "/bin/omarchy-remove-launcher-entry") + " " + Util.shellQuote(id) + " " + Util.shellQuote(String(name || id))) + } + + function normalizeDesktopId(id) { + var value = String(id || "").trim() + if (value.slice(-8) === ".desktop") value = value.slice(0, -8) + return value + } + + function loadConfiguredHides(rawText) { + var next = ({}) + var lines = String(rawText || "").split(/\n/) + for (var i = 0; i < lines.length; i++) { + var id = root.normalizeDesktopId(lines[i]) + if (id.length > 0) next[id] = true + } + root.configuredHiddenEntryIds = next + root.appsChanged() + } + + function loadDesktopHiddenEntries(rawText) { + var next = ({}) + var lines = String(rawText || "").split(/\n/) + for (var i = 0; i < lines.length; i++) { + var id = root.normalizeDesktopId(lines[i]) + if (id.length > 0) next[id] = true + } + root.desktopHiddenEntryIds = next + root.appsChanged() + } + + function iconIndexScanCommand() { + // List app/device icons across the XDG icon dirs and /usr/share/pixmaps as + // "" lines. Some desktop entries, such as Print Settings, use device + // icons like "printer" instead of app icons. SVGs are emitted before PNGs + // so the parser, which keeps the first hit per name, prefers scalable icons. + return [ + 'dirs="$HOME/.icons $HOME/.local/share/icons";', + 'IFS=":"; for d in ${XDG_DATA_DIRS:-/usr/local/share:/usr/share}; do dirs="$dirs $d/icons"; done; unset IFS;', + 'for ext in svg png; do', + ' for base in $dirs; do', + ' [[ -d $base ]] && find "$base" \\( -path "*/apps/*" -o -path "*/devices/*" \\) -name "*.$ext" 2>/dev/null;', + ' done;', + ' find /usr/share/pixmaps -maxdepth 1 -name "*.$ext" 2>/dev/null;', + 'done' + ].join(' ') + } + + function indexIconLine(path) { + var value = String(path || "").trim() + if (value.length === 0) return + var slash = value.lastIndexOf("/") + var file = slash >= 0 ? value.slice(slash + 1) : value + var dot = file.lastIndexOf(".") + var name = dot > 0 ? file.slice(0, dot) : file + if (name.length > 0 && root.pendingIconIndex[name] === undefined) + root.pendingIconIndex[name] = value + } + + function hiddenEntryScanCommand() { + var desktop = [Quickshell.env("XDG_CURRENT_DESKTOP"), Quickshell.env("XDG_SESSION_DESKTOP"), Quickshell.env("DESKTOP_SESSION")].filter(function(v) { return String(v || "").length > 0 }).join(":") + var script = root.omarchyPath + "/shell/services/hidden-entries.sh" + return Util.shellQuote(script) + " " + Util.shellQuote(desktop) + } + + function toplevelCount() { + try { return ToplevelManager.toplevels.values.length } catch (e) { return 0 } + } + + function beginLaunchFeedback(name) { + root.launchSerial++ + root.launchToplevelCount = root.toplevelCount() + root.launchActiveToplevel = ToplevelManager.activeToplevel + root.launchOsdOpen = false + root.launchOsdMessage = "Launching " + String(name || "application") + "…" + launchDelay.restart() + launchTimeout.restart() + } + + function closeLaunchFeedback(serial) { + if (serial !== root.launchSerial) return + launchDelay.stop() + launchTimeout.stop() + if (root.launchOsdOpen) { + Quickshell.execDetached(["omarchy-shell", "osd", "close"]) + root.launchOsdOpen = false + } + } + + function maybeFinishLaunchFeedback() { + if (!launchDelay.running && !launchTimeout.running && !root.launchOsdOpen) return + if (root.toplevelCount() <= root.launchToplevelCount && ToplevelManager.activeToplevel === root.launchActiveToplevel) return + root.closeLaunchFeedback(root.launchSerial) + } + + QtObject { + id: hiddenEntryOutput + property string text: "" + } + + Process { + id: hiddenEntryScan + command: ["bash", "-lc", root.hiddenEntryScanCommand()] + stdout: SplitParser { onRead: function(line) { hiddenEntryOutput.text += line + "\n" } } + onStarted: hiddenEntryOutput.text = "" + onExited: root.loadDesktopHiddenEntries(hiddenEntryOutput.text) + } + + Process { + id: iconIndexScan + command: ["bash", "-lc", root.iconIndexScanCommand()] + stdout: SplitParser { onRead: function(line) { root.indexIconLine(line) } } + onStarted: root.pendingIconIndex = ({}) + // Swapping the property re-evaluates every iconSource() binding, so + // newly found icons appear without rebuilding the list. + onExited: root.iconIndex = root.pendingIconIndex + } + + // Coalesces bursts of app-list changes (a package install touches many + // entries) into a single rescan. + Timer { + id: iconIndexDebounce + interval: 750 + onTriggered: if (!iconIndexScan.running) iconIndexScan.running = true + } + + FileView { + path: root.omarchyPath + "/default/omarchy/launcher.hides" + watchChanges: true + printErrors: false + onLoaded: root.loadConfiguredHides(text()) + onFileChanged: root.loadConfiguredHides(text()) + onLoadFailed: root.loadConfiguredHides("") + } + + Connections { + target: ToplevelManager.toplevels + function onValuesChanged() { root.maybeFinishLaunchFeedback() } + } + + Connections { + target: ToplevelManager + function onActiveToplevelChanged() { root.maybeFinishLaunchFeedback() } + } + + Timer { + id: launchDelay + interval: 2000 + onTriggered: { + if (root.toplevelCount() > root.launchToplevelCount || ToplevelManager.activeToplevel !== root.launchActiveToplevel) return + root.launchOsdOpen = true + Quickshell.execDetached(["omarchy-shell", "osd", "show", JSON.stringify({ icon: "󱓞", message: root.launchOsdMessage, duration: 0 })]) + } + } + + Timer { + id: launchTimeout + interval: 15000 + onTriggered: root.closeLaunchFeedback(root.launchSerial) + } + + Connections { + target: DesktopEntries.applications + function onValuesChanged() { + hiddenEntryScan.running = true + iconIndexDebounce.restart() + root.appsChanged() + } + } + + Component.onCompleted: { + hiddenEntryScan.running = true + iconIndexScan.running = true + } +} diff --git a/shell/plugins/launcher/LauncherSearch.js b/shell/services/AppSearch.js similarity index 100% rename from shell/plugins/launcher/LauncherSearch.js rename to shell/services/AppSearch.js diff --git a/shell/plugins/launcher/hidden-entries.sh b/shell/services/hidden-entries.sh similarity index 100% rename from shell/plugins/launcher/hidden-entries.sh rename to shell/services/hidden-entries.sh diff --git a/shell/shell.qml b/shell/shell.qml index 5c4a6ce3..f8b04fb0 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -17,6 +17,7 @@ ShellRoot { // own empty copies. property PluginRegistry pluginRegistry: PluginRegistry { } property BarWidgetRegistry barWidgetRegistry: BarWidgetRegistry { } + property AppLibrary appLibrary: AppLibrary { } property string home: Quickshell.env("HOME") @@ -422,9 +423,8 @@ ShellRoot { // Bar-widget panels (audio, bluetooth, network, power, monitor, etc.) // are mounted inside the bar, not via the panel loader below. Route // summon/hide/toggle to the live bar instance so panel hotkeys survive - // plugin/bar reloads: the bar re-creates the widget, while a fixed - // IpcHandler target would go stale ("first handler wins" leaves a - // destroyed instance's handler active and the new one rejected). + // plugin/bar reloads: the bar re-creates the widget, while a fixed IPC + // target only ever routes to one of the per-monitor instances. function isBarWidgetPanelPlugin(pluginId) { var plugins = shell.pluginRegistry.installedPlugins var m = plugins[String(pluginId || "")] diff --git a/test/acceptance.d/session-test.sh b/test/acceptance.d/session-test.sh index 8f87dde3..8d8f53b5 100644 --- a/test/acceptance.d/session-test.sh +++ b/test/acceptance.d/session-test.sh @@ -16,7 +16,7 @@ wait_until "omarchy-shell responds to ping" 60 omarchy-shell shell ping plugins=$(omarchy-shell shell listPlugins) for plugin in \ omarchy.audio omarchy.background omarchy.bar omarchy.bluetooth \ - omarchy.clipboard omarchy.emojis omarchy.launcher omarchy.menu \ + omarchy.clipboard omarchy.emojis omarchy.menu \ omarchy.monitor omarchy.network omarchy.notifications omarchy.power \ omarchy.reminders omarchy.weather; do [[ $plugins == *"$plugin"* ]] || fail "shell plugin is loaded: $plugin" "loaded plugins: $plugins" diff --git a/test/acceptance.d/shell-surfaces-test.sh b/test/acceptance.d/shell-surfaces-test.sh index fa9c9b03..9e10e85c 100644 --- a/test/acceptance.d/shell-surfaces-test.sh +++ b/test/acceptance.d/shell-surfaces-test.sh @@ -94,23 +94,24 @@ screenshot "success-notification-popup" omarchy-shell notifications dismissAll >/dev/null wait_until "notification popup closes" 15 layer_absent "omarchy-notifications" -# The launcher does the full loop: open, search by typing, launch the top hit. +# The menu's Apps submenu does the full launcher loop: open, search by +# typing, launch the top hit. if window_present "(?i)omawrite" >/dev/null 2>&1; then - fail "launcher test starts with no Omawrite window" "an Omawrite window is already open" + fail "app launch test starts with no Omawrite window" "an Omawrite window is already open" fi -omarchy-shell shell summon omarchy.launcher >/dev/null -wait_until "launcher opens" 15 layer_present "omarchy-launcher" +omarchy-menu summon apps >/dev/null +wait_until "apps menu opens" 15 layer_present "omarchy-menu" sleep 1 -screenshot "success-launcher-open" +screenshot "success-apps-menu-open" wtype "omawrite" sleep 1 -screenshot "success-launcher-search" +screenshot "success-apps-menu-search" wtype -k Return -wait_until "launcher launches the top search hit" 60 window_present "(?i)omawrite" -wait_until "launcher closes after launching" 15 layer_absent "omarchy-launcher" +wait_until "apps menu launches the top search hit" 60 window_present "(?i)omawrite" +wait_until "apps menu closes after launching" 15 layer_absent "omarchy-menu" close_windows "(?i)omawrite" wait_until "Omawrite window closes" 30 window_absent "(?i)omawrite" diff --git a/test/cli b/test/cli index f192b7ec..7b822d27 100755 --- a/test/cli +++ b/test/cli @@ -423,6 +423,28 @@ for nvim_legacy_target in \ done pass "nvim theme migration relinks current theme" +for nvim_legacy_target in \ + "../../../omarchy/current/theme/neovim.lua" \ + "../../../../.config/omarchy/current/theme/neovim.lua" \ + "~/.config/omarchy/current/theme/neovim.lua" \ + "$NVIM_MIGRATION_TMPDIR/.config/omarchy/current/theme/neovim.lua"; do + ln -sfn "$nvim_legacy_target" "$nvim_theme_link" + HOME="$NVIM_MIGRATION_TMPDIR" bash -euo pipefail "$ROOT/migrations/1785002349.sh" >/dev/null + nvim_actual_target=$(readlink "$nvim_theme_link") + nvim_resolved_target=$(readlink -f "$nvim_theme_link") + [[ $nvim_actual_target == $nvim_expected_target ]] || fail "nvim theme repair migration updates $nvim_legacy_target" + [[ $nvim_resolved_target == $nvim_expected_resolved_target ]] || fail "nvim theme repair migration points to current theme" +done + +HOME="$NVIM_MIGRATION_TMPDIR" bash -euo pipefail "$ROOT/migrations/1785002349.sh" >/dev/null +[[ $(readlink "$nvim_theme_link") == $nvim_expected_target ]] || fail "nvim theme repair migration is idempotent" + +touch "$NVIM_MIGRATION_TMPDIR/custom-theme.lua" +ln -sfn "../../../../custom-theme.lua" "$nvim_theme_link" +HOME="$NVIM_MIGRATION_TMPDIR" bash -euo pipefail "$ROOT/migrations/1785002349.sh" >/dev/null +[[ $(readlink "$nvim_theme_link") == "../../../../custom-theme.lua" ]] || fail "nvim theme repair migration leaves custom symlinks alone" +pass "nvim theme repair migration relinks every legacy spelling" + HYPR_MIGRATION_TMPDIR=$(mktemp -d) mkdir -p "$HYPR_MIGRATION_TMPDIR/.config/hypr" cat >"$HYPR_MIGRATION_TMPDIR/.config/hypr/hyprland.lua" <<'LUA' diff --git a/test/shell.d/app-search-test.sh b/test/shell.d/app-search-test.sh new file mode 100644 index 00000000..44c13bc8 --- /dev/null +++ b/test/shell.d/app-search-test.sh @@ -0,0 +1,130 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const fs = require('fs') +const search = requireFromRoot('shell/services/AppSearch.js') +const menuQml = fs.readFileSync(path.join(root, 'shell/plugins/menu/Menu.qml'), 'utf8') +const appLibraryQml = fs.readFileSync(path.join(root, 'shell/services/AppLibrary.qml'), 'utf8') + +const entries = [ + { + name: 'Google Contacts', + genericName: 'Address Book', + comment: 'Manage contacts', + keywords: ['contacts', 'address book', 'people'], + id: 'google-contacts.desktop' + }, + { + name: 'Calculator', + genericName: 'Calculator', + comment: 'Perform arithmetic, scientific or financial calculations', + keywords: ['calculation', 'arithmetic', 'scientific', 'financial'], + id: 'org.gnome.Calculator.desktop' + }, + { + name: 'OBS Studio', + genericName: 'Streaming/Recording Software', + comment: 'Free and Open Source Streaming/Recording Software', + keywords: ['streaming', 'recording', 'capture'], + id: 'com.obsproject.Studio.desktop' + }, + { + name: 'Aether', + genericName: '', + comment: 'Minimal internet radio player', + keywords: ['audio', 'music', 'radio'], + id: 'io.github.taqi.aether.desktop' + }, + { + name: 'Xournal++', + genericName: 'Notetaking', + comment: 'Take handwritten notes', + keywords: ['notes', 'pdf', 'annotation'], + id: 'com.github.xournalpp.xournalpp.desktop' + }, + { + name: 'RustDesk', + genericName: 'Remote Desktop', + comment: 'Remote desktop control', + keywords: ['remote', 'desktop', 'control'], + id: 'com.rustdesk.RustDesk.desktop' + } +] + +const contactMatches = search.sortedEntries(entries, 'contact').map(row => search.entryName(row.entry)) +assertDeepEqual(contactMatches, ['Google Contacts'], 'contact search only returns direct contact matches') + +assert( + search.fuzzyScore(entries[1], 'contact') < 0, + 'calculator does not match contact as a loose subsequence' +) + +const acronymMatches = search.sortedEntries(entries, 'gc').map(row => search.entryName(row.entry)) +assertEqual(acronymMatches[0], 'Google Contacts', 'short acronym matching still works') + +const directMatches = search.sortedEntries(entries, 'obs').map(row => search.entryName(row.entry)) +assertEqual(directMatches[0], 'OBS Studio', 'direct app-name matching still works') + +// The menu's Apps submenu is the launcher now: app rows launch and uninstall +// through the shared app library instead of running commands themselves. +const activateMatch = menuQml.match(/function activateIndex\(index, fromPointer\) \{([\s\S]*?)\n \}/) +assert(activateMatch, 'menu activateIndex function exists') +assert( + activateMatch[1].includes('root.appLibrary.launch('), + 'menu routes app launch through the shared app library' +) +assert( + !activateMatch[1].includes('entry.execute()'), + 'menu does not execute desktop entries directly' +) + +const confirmDeleteMatch = menuQml.match(/function confirmDelete\(\) \{([\s\S]*?)\n \}/) +assert(confirmDeleteMatch, 'menu confirmDelete function exists') +assert( + confirmDeleteMatch[1].includes('root.appLibrary.remove('), + 'menu delete routes through the shared app library' +) +assert( + confirmDeleteMatch[1].includes('root.cancel()'), + 'menu delete closes the menu after confirmation' +) + +assert( + /function remove\(desktopId, name\) \{[\s\S]*?omarchy-remove-launcher-entry[\s\S]*?\n \}/.test(appLibraryQml), + 'app library remove runs the remover through the shell' +) + +assert( + /function launch\(desktopId, name\) \{[\s\S]*?gtk-launch[\s\S]*?\n \}/.test(appLibraryQml) && + appLibraryQml.includes('Util.execDetached("gtk-launch "'), + 'app library runs desktop entry launch through the shell' +) + +assert( + /function iconIndexScanCommand\(\)[\s\S]*-path "\*\/apps\/\*" -o -path "\*\/devices\/\*"/.test(appLibraryQml), + 'app library fallback icon index includes device icons' +) + +assert( + /if \(active === "apps"\) \{[\s\S]*?rows\.sort\(function\(a, b\)/.test(menuQml), + 'apps menu enforces alphabetical display order after provider refreshes' +) + +const iconSourceMatch = appLibraryQml.match(/function iconSource\(icon\) \{([\s\S]*?)\n \}/) +assert(iconSourceMatch, 'app library iconSource function exists') +assert( + iconSourceMatch[1].indexOf('root.iconIndex[value]') < iconSourceMatch[1].indexOf('Quickshell.iconPath(value, true)'), + 'app library prefers indexed app icons over ambiguous themed icons' +) + +const openMatch = menuQml.match(/function openExistingMenu\(initialMenu\) \{([\s\S]*?)\n \}/) +assert(openMatch, 'menu openExistingMenu function exists') +assert( + openMatch[1].includes('root.appLibrary.refreshIcons()'), + 'menu refreshes the shared icon index when opened' +) +JS diff --git a/test/shell.d/bar-widget-contract-test.sh b/test/shell.d/bar-widget-contract-test.sh index 7cdd5b22..da40d6d2 100755 --- a/test/shell.d/bar-widget-contract-test.sh +++ b/test/shell.d/bar-widget-contract-test.sh @@ -12,7 +12,9 @@ cleanup() { kill "$QS_PID" 2>/dev/null || true wait "$QS_PID" 2>/dev/null || true fi - [[ -n $TMPDIR && -d $TMPDIR ]] && rm -rf "$TMPDIR" + if [[ -n $TMPDIR && -d $TMPDIR ]]; then + rm -rf "$TMPDIR" + fi } trap cleanup EXIT diff --git a/test/shell.d/channel-test.sh b/test/shell.d/channel-test.sh index ce0d1242..664e17c5 100644 --- a/test/shell.d/channel-test.sh +++ b/test/shell.d/channel-test.sh @@ -37,9 +37,16 @@ for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG" ' +write_stub omarchy-state '#!/bin/bash +printf "state" >>"$OMARCHY_CHANNEL_TEST_LOG" +for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done +printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG" +' + write_stub omarchy-update '#!/bin/bash printf "update" >>"$OMARCHY_CHANNEL_TEST_LOG" for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done +printf "\tOMARCHY_PATH=%s" "$OMARCHY_PATH" >>"$OMARCHY_CHANNEL_TEST_LOG" printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG" ' @@ -47,22 +54,9 @@ write_stub gum '#!/bin/bash printf "gum" >>"$OMARCHY_CHANNEL_TEST_LOG" for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done printf "\n" >>"$OMARCHY_CHANNEL_TEST_LOG" -if [[ $1 == "input" ]]; then - printf "%s\n" "${OMARCHY_TEST_GUM_INPUT:-$HOME/Work/omarchy}" -fi exit 0 ' -write_stub omarchy-cmd-missing '#!/bin/bash -[[ $1 == "git" ]] && exit 1 -exit 0 -' - -write_stub omarchy-cmd-present '#!/bin/bash -[[ $1 == "omarchy-dev-unlink" || $1 == "gum" || $1 == "git" ]] && exit 0 -exit 1 -' - write_stub git '#!/bin/bash printf "git" >>"$OMARCHY_CHANNEL_TEST_LOG" for arg in "$@"; do printf "\t%s" "$arg" >>"$OMARCHY_CHANNEL_TEST_LOG"; done @@ -96,9 +90,7 @@ esac run_channel() { : >"$log_file" OMARCHY_CHANNEL_TEST_LOG="$log_file" \ - OMARCHY_DEV_PATH="${OMARCHY_DEV_PATH:-}" \ - OMARCHY_TEST_GUM_INPUT="${OMARCHY_TEST_GUM_INPUT:-}" \ - OMARCHY_PATH="$ROOT" \ + OMARCHY_PATH="${OMARCHY_TEST_PATH:-/usr/share/omarchy}" \ HOME="$test_tmp/home" \ PATH="$stub_bin:$ROOT/bin:$PATH" \ "$ROOT/bin/omarchy-channel-set" "$@" @@ -112,49 +104,35 @@ assert_log_line() { pass "$description" } -assert_numbered_log_line() { - local number="$1" - local expected="$2" - local description="$3" - local actual="" - - actual=$(sed -n "${number}p" "$log_file") - [[ $actual == $expected ]] || fail "$description" "$(cat "$log_file")" - pass "$description" -} - run_channel stable assert_log_line $'refresh\tstable' "stable refreshes the stable pacman channel" assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "stable installs stable Omarchy packages" -assert_log_line 'unlink' "stable restores the package-backed Omarchy path" -assert_log_line $'update\t-y' "stable runs the normal update pipeline" +assert_log_line $'unlink\t--no-reboot' "stable restores the package-backed Omarchy path without an early reboot prompt" +assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "stable runs the normal update pipeline from the package-backed path" +if grep -q $'^state\tset\treboot-required$' "$log_file"; then + fail "stable does not require reboot when already package-backed" "$(cat "$log_file")" +fi +pass "stable does not require reboot when already package-backed" run_channel rc assert_log_line $'refresh\trc' "rc refreshes the rc pacman channel" assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy\tomarchy-settings' "rc installs rc Omarchy packages" -assert_log_line 'unlink' "rc restores the package-backed Omarchy path" -assert_log_line $'update\t-y' "rc runs the normal update pipeline" +assert_log_line $'unlink\t--no-reboot' "rc restores the package-backed Omarchy path without an early reboot prompt" +assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "rc runs the normal update pipeline from the package-backed path" -run_channel edge +OMARCHY_TEST_PATH="$ROOT" run_channel edge assert_log_line $'refresh\tedge' "edge refreshes the edge pacman channel" assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "edge installs development Omarchy packages" -assert_log_line 'unlink' "edge remains package-backed" -assert_log_line $'update\t-y' "edge runs the normal update pipeline" +assert_log_line $'unlink\t--no-reboot' "edge unlinks dev without an early reboot prompt" +assert_log_line $'state\tset\treboot-required' "edge marks reboot required when leaving dev" +assert_log_line $'update\t-y\tOMARCHY_PATH=/usr/share/omarchy' "edge runs the normal update pipeline from the package-backed path" +[[ $(grep -E '^(unlink|state|update)' "$log_file") == $'unlink\t--no-reboot\nstate\tset\treboot-required\nupdate\t-y\tOMARCHY_PATH=/usr/share/omarchy' ]] || + fail "edge defers the reboot prompt until the update restart stage" "$(cat "$log_file")" +pass "edge defers the reboot prompt until the update restart stage" -checkout="$test_tmp/dev-checkout" -default_checkout="$test_tmp/home/Work/omarchy" -OMARCHY_TEST_GUM_INPUT="$checkout" run_channel dev -assert_numbered_log_line 1 $'gum\tconfirm\t--default=false\tEnable Dev anyway?' "dev warns before changing packages" -assert_numbered_log_line 2 $'gum\tinput\t--value\t'"$default_checkout"$'\t--placeholder\t'"$default_checkout"$'\t--header\tWhere should Dev checkout live? Existing non-checkout paths will not be overwritten.' "dev prompts for the checkout path before changing packages" -assert_log_line $'gum\tconfirm\t--default=false\tEnable Dev anyway?' "dev asks for confirmation" -assert_log_line $'refresh\tedge' "dev refreshes the edge pacman channel" -assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "dev installs development Omarchy packages" -assert_log_line $'git\tclone\t--branch\tquattro\t--single-branch\thttps://github.com/basecamp/omarchy.git\t'"$checkout" "dev clones the quattro checkout" -assert_log_line $'link\t'"$checkout" "dev links the source checkout" - -occupied_checkout="$test_tmp/occupied" -mkdir -p "$occupied_checkout" -if OMARCHY_TEST_GUM_INPUT="$occupied_checkout" run_channel dev >"$test_tmp/occupied.out" 2>"$test_tmp/occupied.err"; then +checkout="$test_tmp/home/omarchy" +mkdir -p "$checkout" +if run_channel dev >"$test_tmp/occupied.out" 2>"$test_tmp/occupied.err"; then fail "dev refuses to use an occupied non-checkout path" fi @@ -164,6 +142,30 @@ if grep -Fx $'refresh\tedge' "$log_file" >/dev/null; then fi pass "dev refuses occupied non-checkout paths before package changes" +rmdir "$checkout" +run_channel dev +assert_log_line $'gum\tconfirm\t--default=false\tSwitch to dev channel?' "dev asks for confirmation" +assert_log_line $'refresh\tedge' "dev refreshes the edge pacman channel" +assert_log_line $'sudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev' "dev installs development Omarchy packages" +assert_log_line $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout" "dev clones the source checkout to ~/omarchy" +assert_log_line $'link\t'"$checkout"$'\t--no-reboot' "dev links ~/omarchy without an early reboot prompt" +assert_log_line $'state\tset\treboot-required' "dev defers the reboot prompt to the update pipeline" +assert_log_line $'update\t-y\tOMARCHY_PATH='"$checkout" "dev runs the normal update pipeline from the source checkout" +[[ $(grep -E '^(git|link|state|refresh|sudo|update)' "$log_file") == $'git\tclone\thttps://github.com/basecamp/omarchy.git\t'"$checkout"$'\nlink\t'"$checkout"$'\t--no-reboot\nstate\tset\treboot-required\nrefresh\tedge\nsudo\tenv\tOMARCHY_UPDATE_PACMAN=1\tpacman\t-S\t--needed\t--noconfirm\t--ask\t4\tomarchy-dev\tomarchy-settings-dev\nupdate\t-y\tOMARCHY_PATH='"$checkout" ]] || + fail "dev activates the checkout before changing or updating packages" "$(cat "$log_file")" +pass "dev activates the checkout before changing or updating packages" + +OMARCHY_TEST_PATH="$checkout" run_channel stable +assert_log_line $'unlink\t--no-reboot' "switching from dev to stable unlinks without an early reboot prompt" +assert_log_line $'state\tset\treboot-required' "switching from dev to stable marks reboot required" + +run_channel dev +if grep -q $'^git\tclone\t' "$log_file"; then + fail "dev reuses an existing checkout" "$(cat "$log_file")" +fi +assert_log_line $'link\t'"$checkout"$'\t--no-reboot' "switching back to dev links ~/omarchy" +pass "switching back to dev reuses the existing ~/omarchy checkout" + current_channel() { OMARCHY_TEST_VERSION_CHANNEL="$1" \ OMARCHY_TEST_PACKAGES="$2" \ @@ -182,4 +184,4 @@ pass "current channel detects rc" pass "current channel detects package-backed edge" [[ $(current_channel edge dev "$test_tmp/dev-checkout") == "dev" ]] || fail "current channel detects dev from OMARCHY_PATH" -pass "current channel detects dev from OMARCHY_PATH" +pass "current channel honors a dev link outside ~/omarchy" diff --git a/test/shell.d/config-test.sh b/test/shell.d/config-test.sh index 6a036c1a..65968e90 100755 --- a/test/shell.d/config-test.sh +++ b/test/shell.d/config-test.sh @@ -94,12 +94,29 @@ import sys from pathlib import Path root = Path(os.environ["ROOT"]) +home = Path.home() pkgs_candidates = [ root.parent / "omarchy-pkgs/pkgbuilds", root.parent / "omarchy/omarchy-pkgs/pkgbuilds", root.parent.parent / "omarchy-pkgs/pkgbuilds", + root.parent / "omacom/omarchy-pkgs/pkgbuilds", + root.parent.parent / "omacom/omarchy-pkgs/pkgbuilds", + home / "Work/omacom/omarchy-pkgs/pkgbuilds", ] -pkgs_root = next((path for path in pkgs_candidates if path.exists()), pkgs_candidates[0]) +# Checkouts differ per machine, so allow an explicit pointer at the sibling repo. +# Accepts either the omarchy-pkgs checkout or its pkgbuilds/ directory. +override = os.environ.get("OMARCHY_PKGS_PATH") +if override: + pkgs_candidates = [Path(override) / "pkgbuilds", Path(override)] + pkgs_candidates +pkgs_root = next((path for path in pkgs_candidates if path.exists()), None) +if pkgs_root is None: + print("not ok - omarchy-pkgs checkout found for PKGBUILD coverage", file=sys.stderr) + print( + "looked in:\n " + "\n ".join(str(path) for path in pkgs_candidates) + + "\nset OMARCHY_PKGS_PATH to the omarchy-pkgs checkout", + file=sys.stderr, + ) + sys.exit(1) settings_pkgbuild_path = pkgs_root / "omarchy-settings/PKGBUILD" omarchy_pkgbuild_path = pkgs_root / "omarchy/PKGBUILD" if not settings_pkgbuild_path.exists(): @@ -120,8 +137,8 @@ package_defaults = [ ("default/systemd/user/bt-agent.service", "/usr/lib/systemd/user/bt-agent.service", "systemd/user/bt-agent.service"), ("default/systemd/user/omarchy-sleep-lock.service", "/usr/lib/systemd/user/omarchy-sleep-lock.service", "systemd/user/omarchy-sleep-lock.service"), ("default/systemd/user/omarchy-recover-internal-monitor.service", "/usr/lib/systemd/user/omarchy-recover-internal-monitor.service", "systemd/user/omarchy-recover-internal-monitor.service"), - ("default/systemd/user/omarchy-update-user-notify.service", "/usr/lib/systemd/user/omarchy-update-user-notify.service", "systemd/user/omarchy-update-user-notify.service"), - ("default/systemd/user/omarchy-update-user-notify.path", "/usr/lib/systemd/user/omarchy-update-user-notify.path", "systemd/user/omarchy-update-user-notify.path"), + ("default/systemd/user/omarchy-migrate-notify.service", "/usr/lib/systemd/user/omarchy-migrate-notify.service", "systemd/user/omarchy-migrate-notify.service"), + ("default/systemd/zram-generator.conf.d/90-omarchy.conf", "/usr/lib/systemd/zram-generator.conf.d/90-omarchy.conf", "systemd/zram-generator.conf.d/90-omarchy.conf"), ("default/fonts/omarchy/omarchy.ttf", "/usr/share/fonts/omarchy/omarchy.ttf", "omarchy.ttf"), ("default/snapper/root", "/etc/snapper/config-templates/omarchy", "snapper/root"), ] @@ -134,6 +151,16 @@ for source, destination, legacy in package_defaults: if destination and (source not in pkgbuild or destination not in pkgbuild): errors.append(f"PKGBUILD does not explicitly install {source} -> {destination}") +# Existing users have an absolute wants symlink to the old unit path, and the +# migration that repoints it only runs for users who run an update -- the +# opposite of who the notifier is for. Dropping this alias strands them. +notify_alias = 'ln -sfn omarchy-migrate-notify.service "$pkgdir/usr/lib/systemd/user/omarchy-update-user-notify.service"' +if notify_alias not in pkgbuild: + errors.append( + "PKGBUILD does not ship the omarchy-update-user-notify.service compatibility " + "alias, so users who have not run migration 1785095882 lose the login notifier" + ) + alpm_hooks = [ "00-omarchy-update-guard.hook", "10-omarchy-hyprland-reload-pause.hook", @@ -249,6 +276,27 @@ jq -e ' ' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null pass "shell config moves existing widgets without duplicates" +HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin move omarchy.active-window right +jq -e ' + def ids: map(.id // .); + (.bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces"]) and + (.bar.layout.right | ids == ["omarchy.tray", "omarchy.active-window", "omarchy.microphone", "omarchy.tailscale", "omarchy.bluetooth"]) +' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null +pass "bar plugin move accepts a positional target section" + +HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin move omarchy.active-window left +jq -e ' + def ids: map(.id // .); + (.bar.layout.left | ids == ["omarchy.menu", "omarchy.workspaces", "omarchy.active-window"]) and + (.bar.layout.right | ids == ["omarchy.tray", "omarchy.microphone", "omarchy.tailscale", "omarchy.bluetooth"]) +' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null +pass "bar plugin move can restore a widget with positional syntax" + +if HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin move omarchy.active-window left --section right 2>/dev/null; then + fail "bar plugin move accepted positional and flagged target sections" +fi +pass "bar plugin move rejects conflicting target section syntax" + if HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin add local.nonexistent-widget 2>/dev/null; then fail "bar plugin add accepted an unknown widget" fi @@ -289,6 +337,28 @@ jq -e ' ' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null pass "shell config removes widgets with remove alias" +HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin set omarchy.bluetooth enabled false --json +jq -e ' + any(.bar.layout.right[]; .id == "omarchy.bluetooth" and .enabled == false) +' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null +pass "bar plugin set accepts false JSON values" + +HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin set omarchy.bluetooth optional null --json +jq -e ' + any(.bar.layout.right[]; .id == "omarchy.bluetooth" and has("optional") and .optional == null) +' "$TMPDIR/home/.config/omarchy/shell.json" >/dev/null +pass "bar plugin set accepts null JSON values" + +if HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin set omarchy.bluetooth broken '{' --json 2>/dev/null; then + fail "bar plugin set accepted malformed JSON" +fi +pass "bar plugin set rejects malformed JSON" + +if HOME="$TMPDIR/home" OMARCHY_PATH="$ROOT" omarchy-bar-plugin set omarchy.bluetooth broken 'false null' --json 2>/dev/null; then + fail "bar plugin set accepted multiple JSON values" +fi +pass "bar plugin set rejects multiple JSON values" + mock_bin="$TMPDIR/mock-bin" mkdir -p "$mock_bin" diff --git a/test/shell.d/dev-unlink-test.sh b/test/shell.d/dev-unlink-test.sh new file mode 100644 index 00000000..fb952fca --- /dev/null +++ b/test/shell.d/dev-unlink-test.sh @@ -0,0 +1,82 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +log_file="$test_tmp/dev-unlink.log" +conf_file="$test_tmp/omarchy.conf" +mkdir -p "$stub_bin" "$test_tmp/home" + +cat >"$stub_bin/sudo" <<'SH' +#!/bin/bash + +printf 'sudo' >>"$OMARCHY_DEV_UNLINK_TEST_LOG" +for arg in "$@"; do + printf '\t%s' "$arg" >>"$OMARCHY_DEV_UNLINK_TEST_LOG" +done +printf '\n' >>"$OMARCHY_DEV_UNLINK_TEST_LOG" + +if [[ $1 == "tee" ]]; then + cat >"$OMARCHY_DEV_UNLINK_TEST_CONF" +fi +SH +chmod +x "$stub_bin/sudo" + +cat >"$stub_bin/gum" <<'SH' +#!/bin/bash + +printf 'gum' >>"$OMARCHY_DEV_UNLINK_TEST_LOG" +for arg in "$@"; do + printf '\t%s' "$arg" >>"$OMARCHY_DEV_UNLINK_TEST_LOG" +done +printf '\n' >>"$OMARCHY_DEV_UNLINK_TEST_LOG" +SH +chmod +x "$stub_bin/gum" + +cat >"$stub_bin/omarchy-system-reboot" <<'SH' +#!/bin/bash + +printf 'reboot\n' >>"$OMARCHY_DEV_UNLINK_TEST_LOG" +SH +chmod +x "$stub_bin/omarchy-system-reboot" + +run_unlink() { + HOME="$test_tmp/home" \ + OMARCHY_DEV_UNLINK_TEST_LOG="$log_file" \ + OMARCHY_DEV_UNLINK_TEST_CONF="$conf_file" \ + PATH="$stub_bin:$PATH" \ + "$ROOT/bin/omarchy-dev-unlink" "$@" +} + +: >"$log_file" +run_unlink --no-reboot + +grep -Fx $'sudo\ttee\t/etc/omarchy.conf' "$log_file" >/dev/null || + fail "dev unlink writes the package path without rebooting" "$(cat "$log_file")" +[[ $(<"$conf_file") == 'export OMARCHY_PATH="/usr/share/omarchy"' ]] || + fail "dev unlink writes the package path guard" "$(<"$conf_file")" +if grep -Eq '^(gum|reboot)' "$log_file"; then + fail "dev unlink --no-reboot skips the reboot prompt" "$(cat "$log_file")" +fi +pass "dev unlink --no-reboot skips the reboot prompt" + +: >"$log_file" +run_unlink + +grep -Fx $'gum\tconfirm\tReboot now to activate?' "$log_file" >/dev/null || + fail "interactive dev unlink still prompts for reboot" "$(cat "$log_file")" +grep -Fx 'reboot' "$log_file" >/dev/null || + fail "interactive dev unlink still reboots after confirmation" "$(cat "$log_file")" +pass "interactive dev unlink keeps its reboot prompt" + +if run_unlink --invalid >"$test_tmp/invalid.out" 2>"$test_tmp/invalid.err"; then + fail "dev unlink rejects unknown arguments" +fi +grep -F 'Usage: omarchy dev unlink [--no-reboot]' "$test_tmp/invalid.err" >/dev/null || + fail "dev unlink explains valid arguments" "$(cat "$test_tmp/invalid.err")" +pass "dev unlink rejects unknown arguments" diff --git a/test/shell.d/fingerprint-invitation-test.sh b/test/shell.d/fingerprint-invitation-test.sh new file mode 100644 index 00000000..a746530e --- /dev/null +++ b/test/shell.d/fingerprint-invitation-test.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +source "$(dirname "$0")/base-test.sh" + +# The hook guards on the real /etc/pam.d path, which can't be mocked via PATH. +if [[ -f /etc/pam.d/omarchy-lock-fingerprint ]]; then + pass "fingerprint invitation test skipped: host already has fingerprint auth configured" + exit 0 +fi + +test_home=$(mktemp -d) +test_bin=$(mktemp -d) +log_file=$(mktemp) +hw_marker=$(mktemp -u) +hook_path="$test_home/.config/omarchy/hooks/post-update.d/setup-fingerprint.hook" + +cleanup() { + rm -rf "$test_home" "$test_bin" + rm -f "$log_file" "$hw_marker" +} +trap cleanup EXIT + +mkdir -p "$(dirname "$hook_path")" + +cat >"$test_bin/omarchy-hw-fingerprint" <<'EOF' +#!/bin/bash +[[ -f $TEST_HW_MARKER ]] +EOF +chmod +x "$test_bin/omarchy-hw-fingerprint" + +cat >"$test_bin/omarchy-notification-send" <<'EOF' +#!/bin/bash +echo notification >>"$TEST_LOG" +echo action +EOF +chmod +x "$test_bin/omarchy-notification-send" + +cat >"$test_bin/omarchy-launch-floating-terminal-with-presentation" <<'EOF' +#!/bin/bash +echo launch >>"$TEST_LOG" +EOF +chmod +x "$test_bin/omarchy-launch-floating-terminal-with-presentation" + +cat >"$test_bin/systemd-run" <<'EOF' +#!/bin/bash +echo "systemd-run:$*" >>"$TEST_LOG" +while (($# > 0)); do + case $1 in + -p) shift 2 ;; + -*) shift ;; + *) break ;; + esac +done +exec "$@" +EOF +chmod +x "$test_bin/systemd-run" + +run_invitation_hook() { + cp "$ROOT/install/user/first-run/setup-fingerprint.hook" "$hook_path" + HOME="$test_home" PATH="$test_bin:$ROOT/bin:$PATH" TEST_LOG="$log_file" TEST_HW_MARKER="$hw_marker" bash "$hook_path" +} + +run_invitation_hook + +[[ ! -f $test_home/.local/state/omarchy/done/fingerprint-setup-invitation ]] || fail "fingerprint invitation stays pending without a reader" +[[ ! -s $log_file ]] || fail "fingerprint invitation does nothing without a reader" + +touch "$hw_marker" +run_invitation_hook + +[[ -f $test_home/.local/state/omarchy/done/fingerprint-setup-invitation ]] || fail "fingerprint invitation records completion" +[[ -f $hook_path ]] || fail "fingerprint invitation keeps its hook installed" +[[ $(grep -c '^systemd-run:' "$log_file") -eq 2 ]] || fail "fingerprint invitation uses durable user services" +grep -q -- '--user --collect --quiet --service-type=exec --unit=omarchy-fingerprint-setup-invitation' "$log_file" || fail "fingerprint invitation configures its user service" +# KillMode=process keeps the launcher's setsid child alive once the short-lived +# main process exits, otherwise the setup terminal never appears. +grep -q -- '--user --collect --quiet -p KillMode=process --unit=omarchy-setup-security-fingerprint ' "$log_file" || fail "fingerprint invitation outlives its launcher unit" +[[ $(grep -c '^notification$' "$log_file") -eq 1 ]] || fail "fingerprint invitation sends one notification" +[[ $(grep -c '^launch$' "$log_file") -eq 1 ]] || fail "fingerprint invitation handles the notification action" + +HOME="$test_home" PATH="$test_bin:$ROOT/bin:$PATH" TEST_LOG="$log_file" TEST_HW_MARKER="$hw_marker" bash "$hook_path" + +[[ $(grep -c '^systemd-run:' "$log_file") -eq 2 ]] || fail "completed fingerprint invitation does not schedule again" +[[ $(grep -c '^notification$' "$log_file") -eq 1 ]] || fail "completed fingerprint invitation hook does not notify again" + +pass "fingerprint invitation waits for a reader and only runs once" diff --git a/test/shell.d/fixtures/bar-widget-contract/shell.qml b/test/shell.d/fixtures/bar-widget-contract/shell.qml index f06260db..34c2885d 100644 --- a/test/shell.d/fixtures/bar-widget-contract/shell.qml +++ b/test/shell.d/fixtures/bar-widget-contract/shell.qml @@ -110,24 +110,13 @@ ShellRoot { Item { id: host } - QtObject { - id: mockNotificationService - property bool doNotDisturb: false - property ListModel pendingModel: ListModel {} - property ListModel pastModel: ListModel {} - function setDoNotDisturb(value) { doNotDisturb = !!value } - } - QtObject { id: mockShell property var bar: fakeBar property var barConfig: ({ position: "top" }) property var shellConfig: ({ version: 1, idle: {}, plugins: [], bar: { layout: { left: [], center: [], right: [] } } }) - function firstPartyServiceFor(id) { - if (id === "omarchy.notifications") return mockNotificationService - return null - } - function serviceFor(id) { return firstPartyServiceFor(id) } + function firstPartyServiceFor(id) { return null } + function serviceFor(id) { return null } function summon(id, payloadJson) { return true } function hide(id) { return true } function toggle(id, payloadJson) { return true } diff --git a/test/shell.d/fixtures/lock-fingerprint-indicator/shell.qml b/test/shell.d/fixtures/lock-fingerprint-indicator/shell.qml new file mode 100644 index 00000000..1a5f92fe --- /dev/null +++ b/test/shell.d/fixtures/lock-fingerprint-indicator/shell.qml @@ -0,0 +1,108 @@ +import QtQuick +import Quickshell +import qs.Commons + +ShellRoot { + id: root + + readonly property string resultPath: Quickshell.env("OMARCHY_QML_TEST_RESULT") + readonly property string rootPath: Quickshell.env("OMARCHY_PATH") + property var failures: [] + + function fail(message) { + failures.push(String(message)) + } + + function assertTrue(condition, message) { + if (!condition) fail(message) + } + + function shellQuote(value) { + return "'" + String(value).replace(/'/g, "'\\''") + "'" + } + + function writeResult() { + var payload = JSON.stringify({ + ok: failures.length === 0, + failures: failures + }) + + if (resultPath) { + Quickshell.execDetached(["bash", "-lc", "printf '%s' " + shellQuote(payload) + " > " + shellQuote(resultPath)]) + } + } + + Item { id: host; width: 800; height: 600 } + + TextMetrics { + id: probe + font.family: Style.font.family + } + + Timer { + interval: 1 + running: true + repeat: false + onTriggered: { + try { + var component = Qt.createComponent("file://" + root.rootPath + "/shell/plugins/lock/LockView.qml", Component.PreferSynchronous) + if (component.status !== Component.Ready) { + root.fail("LockView failed to load: " + component.errorString()) + return + } + + var view = component.createObject(host, { width: 800, height: 600, loadBackground: false }) + if (!view) { + root.fail("LockView failed to instantiate: " + component.errorString()) + return + } + + var indicator = view.children ? findByObjectName(view, "fingerprintIndicator") : null + root.assertTrue(indicator !== null, "fingerprint indicator exists in the lock view") + + if (indicator) { + view.fingerprintConfigured = false + root.assertTrue(!indicator.visible, "fingerprint indicator is hidden when no sensor is configured") + + view.fingerprintConfigured = true + root.assertTrue(indicator.visible, "fingerprint indicator is shown when a sensor is configured") + + // The field reserves space for the icon so a long password can never + // slide underneath it. The reserve must exceed the icon's own width + // (leaving a gap), and the shrunk dots must fit the reserved-clear + // area even at extreme lengths. + root.assertTrue(view.fingerprintReserve > indicator.width, + "reserved space exceeds the icon width, got reserve " + view.fingerprintReserve + " vs icon " + indicator.width) + + view.passwordText = "x".repeat(80) + var clearWidth = view.fieldWidth - 2 * view.fingerprintReserve + probe.font.pixelSize = Math.max(1, Math.floor(view.passwordDotFontSize * view.passwordDotScale)) + probe.font.letterSpacing = view.passwordDotLetterSpacing * view.passwordDotScale + probe.text = "●".repeat(80) + root.assertTrue(probe.advanceWidth <= clearWidth, + "80 dots stay clear of the fingerprint icon, need " + probe.advanceWidth + "px of " + clearWidth) + + view.fingerprintConfigured = false + root.assertTrue(view.fingerprintReserve === 0, "no space is reserved when no sensor is configured") + } + + view.destroy() + } catch (error) { + root.fail("lock fingerprint indicator fixture threw: " + error) + } finally { + root.writeResult() + } + } + } + + function findByObjectName(node, name) { + if (!node) return null + if (node.objectName === name) return node + var kids = node.children || [] + for (var i = 0; i < kids.length; i++) { + var found = findByObjectName(kids[i], name) + if (found) return found + } + return null + } +} diff --git a/test/shell.d/fixtures/manifest-entrypoints/shell.qml b/test/shell.d/fixtures/manifest-entrypoints/shell.qml index c9a6fa34..7605eb4e 100644 --- a/test/shell.d/fixtures/manifest-entrypoints/shell.qml +++ b/test/shell.d/fixtures/manifest-entrypoints/shell.qml @@ -137,24 +137,13 @@ ShellRoot { function rescan() {} } - QtObject { - id: mockNotificationService - property bool doNotDisturb: false - property ListModel pendingModel: ListModel {} - property ListModel pastModel: ListModel {} - function setDoNotDisturb(value) { doNotDisturb = !!value } - } - QtObject { id: mockShell property var bar: fakeBar property var barConfig: ({ position: "top" }) property var shellConfig: ({ version: 1, idle: {}, plugins: [], bar: { layout: { left: [], center: [], right: [] } } }) - function firstPartyServiceFor(id) { - if (id === "omarchy.notifications") return mockNotificationService - return null - } - function serviceFor(id) { return firstPartyServiceFor(id) } + function firstPartyServiceFor(id) { return null } + function serviceFor(id) { return null } function summon(id, payloadJson) { return true } function hide(id) { return true } function toggle(id, payloadJson) { return true } diff --git a/test/shell.d/hyprland-default-config-test.sh b/test/shell.d/hyprland-default-config-test.sh index eef81760..e9cabd17 100644 --- a/test/shell.d/hyprland-default-config-test.sh +++ b/test/shell.d/hyprland-default-config-test.sh @@ -105,6 +105,20 @@ grep -Fq $'SUPER + RETURN Terminal' <<<"$fresh_output" || fail "default applicat grep -Fq $'SUPER + SHIFT + A ChatGPT' <<<"$fresh_output" || fail "default application bindings include preinstalled web apps" pass "default application bindings load from package defaults" +grep -F 'hl.dsp.send_key_state({ mods = mods, key = key, state = "down" })' "$ROOT/default/hypr/bindings/clipboard.lua" >/dev/null || + fail "universal clipboard shortcuts send explicit mods to the focused surface" +pass "universal clipboard shortcuts send explicit mods to the focused surface" + +if grep -E 'send_key_state\(\{[^}]*window' "$ROOT/default/hypr/bindings/clipboard.lua" >/dev/null; then + fail "universal clipboard shortcuts do not target only normal windows" +fi +pass "universal clipboard shortcuts do not exclude layer-shell fields" + +if grep -F 'wtype -M' "$ROOT/default/hypr/bindings/clipboard.lua" >/dev/null; then + fail "universal clipboard shortcuts avoid the virtual keyboard so held SUPER cannot merge in" +fi +pass "universal clipboard shortcuts avoid virtual keyboard modifier merging" + removed_home="$tmpdir/removed-home" mkdir -p "$removed_home/.local/state/omarchy" touch "$removed_home/.local/state/omarchy/preinstalls-removed" diff --git a/test/shell.d/hyprland-keyboard-layout-test.sh b/test/shell.d/hyprland-keyboard-layout-test.sh new file mode 100644 index 00000000..68626cc8 --- /dev/null +++ b/test/shell.d/hyprland-keyboard-layout-test.sh @@ -0,0 +1,85 @@ +#!/bin/bash + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +require_command lua + +resolved_input() { + OMARCHY_PATH="$ROOT" OMARCHY_VCONSOLE="${1-}" lua <<'LUA' +package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path + +local vconsole = os.getenv("OMARCHY_VCONSOLE") +local real_open = io.open + +io.open = function(path, mode) + if path ~= "/etc/vconsole.conf" then + return real_open(path, mode) + end + + if not vconsole then + return nil + end + + local file = io.tmpfile() + file:write(vconsole) + file:seek("set") + return file +end + +hl = { + config = function(config) + local input = config.input + print(("[%s] [%s] [%s]"):format(input.kb_layout, input.kb_variant, input.kb_options)) + end, +} + +o = { window = function() end } + +require("default.hypr.input") +LUA +} + +assert_input() { + local description="$1" + local expected="$2" + local actual + + if (( $# > 2 )); then + actual=$(resolved_input "$3") + else + actual=$(resolved_input) + fi + + [[ $actual == "$expected" ]] || + fail "$description" "expected: $expected"$'\n'"actual: $actual" + pass "$description" +} + +base_options="compose:caps,shift:both_capslock" +toggle_options="$base_options,grp:alts_toggle" + +assert_input "missing vconsole.conf falls back to us" "[us] [] [$base_options]" +assert_input "us layout passes through" "[us] [intl] [$base_options]" 'XKBLAYOUT=us +XKBVARIANT=intl +' +assert_input "latin layouts are left alone" "[de] [nodeadkeys] [$base_options]" 'XKBLAYOUT=de +XKBVARIANT=nodeadkeys +' +assert_input "non-latin layout gains us in front" "[us,ara] [,] [$toggle_options]" 'XKBLAYOUT=ara +' +assert_input "prepended us keeps variants aligned" "[us,ru] [,phonetic] [$toggle_options]" 'XKBLAYOUT=ru +XKBVARIANT=phonetic +' +assert_input "non-latin layout in front gains us even when us trails" "[us,il,us] [,] [$toggle_options]" 'XKBLAYOUT=il,us +' + +hooks_conf="$ROOT/etc/mkinitcpio.conf.d/omarchy_hooks.conf" +input_lua="$ROOT/default/hypr/input.lua" + +hooks_layouts=$(awk -F')' '/\) ;;$/ { gsub(/[[:space:]|]+/, "\n", $1); print $1 }' "$hooks_conf" | grep '^[a-z]\+$' | sort) +lua_layouts=$(sed -n '/^local non_latin_layouts =/,+1p' "$input_lua" | grep -o '"[^"]*"' | tr -d '"' | tr ' ' '\n' | grep '^[a-z]\+$' | sort) + +[[ -n $hooks_layouts ]] || fail "non-latin layout list is readable from omarchy_hooks.conf" +[[ $hooks_layouts == "$lua_layouts" ]] || + fail "non-latin layout lists stay in sync" "$(diff <(echo "$hooks_layouts") <(echo "$lua_layouts"))" +pass "non-latin layout lists stay in sync with the initramfs hook" diff --git a/test/shell.d/hyprland-workspace-layout-test.sh b/test/shell.d/hyprland-workspace-layout-test.sh new file mode 100755 index 00000000..ef8b72fe --- /dev/null +++ b/test/shell.d/hyprland-workspace-layout-test.sh @@ -0,0 +1,69 @@ +#!/bin/bash + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +require_command lua + +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +stub_dir="$tmpdir/bin" +home_dir="$tmpdir/home" +log_file="$tmpdir/hyprctl.log" +mkdir -p "$stub_dir" "$home_dir" + +cat >"$stub_dir/hyprctl" <<'EOF' +#!/bin/bash + +if [[ $1 == "activeworkspace" && -n $HYPRCTL_BROKEN ]]; then + printf '{}\n' +elif [[ $1 == "activeworkspace" ]]; then + printf '{"id":3,"tiledLayout":"dwindle"}\n' +else + printf '%s\n' "$*" >>"$HYPRCTL_LOG" +fi +EOF +chmod +x "$stub_dir/hyprctl" + +cat >"$stub_dir/omarchy-notification-send" <<'EOF' +#!/bin/bash +: +EOF +chmod +x "$stub_dir/omarchy-notification-send" + +HOME="$home_dir" HYPRCTL_LOG="$log_file" PATH="$stub_dir:$PATH" \ + "$ROOT/bin/omarchy-hyprland-workspace-layout-toggle" + +layout_file="$home_dir/.local/state/omarchy/workspace-layouts/3.lua" +[[ -f $layout_file ]] || fail "workspace layout toggle saves a workspace rule" +grep -Fx 'hl.workspace_rule({ workspace = "3", layout = "scrolling" })' "$layout_file" >/dev/null || + fail "workspace layout toggle saves the selected layout" +grep -Fx 'eval hl.workspace_rule({ workspace = "3", layout = "scrolling" })' "$log_file" >/dev/null || + fail "workspace layout toggle applies the selected layout immediately" +pass "workspace layout toggle persists and applies the selected layout" + +if HOME="$home_dir" HYPRCTL_LOG="$log_file" HYPRCTL_BROKEN=1 PATH="$stub_dir:$PATH" \ + "$ROOT/bin/omarchy-hyprland-workspace-layout-toggle" 2>/dev/null; then + fail "workspace layout toggle exits nonzero without a workspace id" +fi +[[ -f "$home_dir/.local/state/omarchy/workspace-layouts/null.lua" ]] && + fail "workspace layout toggle does not persist a rule without a workspace id" +pass "workspace layout toggle ignores broken hyprctl output" + +HOME="$home_dir" OMARCHY_PATH="$ROOT" lua <<'LUA' +local rules = {} + +hl = { + workspace_rule = function(rule) + table.insert(rules, rule) + end, +} + +dofile(os.getenv("OMARCHY_PATH") .. "/default/hypr/bootstrap.lua") +require("default.hypr.workspace-layouts") + +assert(#rules == 1) +assert(rules[1].workspace == "3") +assert(rules[1].layout == "scrolling") +LUA +pass "saved workspace layouts load into Hyprland configuration" diff --git a/test/shell.d/launcher-search-test.sh b/test/shell.d/launcher-search-test.sh deleted file mode 100644 index 97a6449a..00000000 --- a/test/shell.d/launcher-search-test.sh +++ /dev/null @@ -1,145 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" - -run_node_test <<'JS' -const fs = require('fs') -const search = requireFromRoot('shell/plugins/launcher/LauncherSearch.js') -const launcherQml = fs.readFileSync(path.join(root, 'shell/plugins/launcher/Launcher.qml'), 'utf8') - -const entries = [ - { - name: 'Google Contacts', - genericName: 'Address Book', - comment: 'Manage contacts', - keywords: ['contacts', 'address book', 'people'], - id: 'google-contacts.desktop' - }, - { - name: 'Calculator', - genericName: 'Calculator', - comment: 'Perform arithmetic, scientific or financial calculations', - keywords: ['calculation', 'arithmetic', 'scientific', 'financial'], - id: 'org.gnome.Calculator.desktop' - }, - { - name: 'OBS Studio', - genericName: 'Streaming/Recording Software', - comment: 'Free and Open Source Streaming/Recording Software', - keywords: ['streaming', 'recording', 'capture'], - id: 'com.obsproject.Studio.desktop' - }, - { - name: 'Aether', - genericName: '', - comment: 'Minimal internet radio player', - keywords: ['audio', 'music', 'radio'], - id: 'io.github.taqi.aether.desktop' - }, - { - name: 'Xournal++', - genericName: 'Notetaking', - comment: 'Take handwritten notes', - keywords: ['notes', 'pdf', 'annotation'], - id: 'com.github.xournalpp.xournalpp.desktop' - }, - { - name: 'RustDesk', - genericName: 'Remote Desktop', - comment: 'Remote desktop control', - keywords: ['remote', 'desktop', 'control'], - id: 'com.rustdesk.RustDesk.desktop' - } -] - -const contactMatches = search.sortedEntries(entries, 'contact').map(row => search.entryName(row.entry)) -assertDeepEqual(contactMatches, ['Google Contacts'], 'contact search only returns direct contact matches') - -assert( - search.fuzzyScore(entries[1], 'contact') < 0, - 'calculator does not match contact as a loose subsequence' -) - -const acronymMatches = search.sortedEntries(entries, 'gc').map(row => search.entryName(row.entry)) -assertEqual(acronymMatches[0], 'Google Contacts', 'short acronym matching still works') - -const directMatches = search.sortedEntries(entries, 'obs').map(row => search.entryName(row.entry)) -assertEqual(directMatches[0], 'OBS Studio', 'direct app-name matching still works') - -assert( - /function select\(delta\)[\s\S]*root\.disarmHover\(\)[\s\S]*root\.selectedIndex =/.test(launcherQml), - 'launcher keyboard navigation disarms stale hover before moving selection' -) -assert( - /PointerMoveGate\s*\{[\s\S]*id: pointerGate[\s\S]*referenceItem: card[\s\S]*\}/.test(launcherQml), - 'launcher uses shared pointer movement gate in card coordinates' -) -assert( - /function disarmHover\(\)[\s\S]*pointerGate\.reset\(\)/.test(launcherQml), - 'launcher resets pointer movement gate when hover is disarmed' -) -const openMatch = launcherQml.match(/function open\(payloadJson\) \{([\s\S]*?)\n \}/) -assert(openMatch, 'launcher open function exists') -assert( - openMatch[1].indexOf('root.disarmHover()') < openMatch[1].indexOf('root.opened = true') - && !openMatch[1].includes('pointerGate.allowInitialSample()'), - 'launcher ignores a stale hidden-pointer position when becoming visible' -) -assert( - /function selectFromPointer\(index, item, mouse\)[\s\S]*pointerGate\.moved\(item, mouse\)[\s\S]*root\.selectedIndex = index/.test(launcherQml), - 'launcher only selects from pointer after real movement' -) -assert( - /onPositionChanged: function\(mouse\) \{\s*root\.selectFromPointer\(row\.index, row, mouse\)\s*\}/.test(launcherQml), - 'launcher row hover routes through pointer movement gate' -) -assert( - /onEntered: root\.selectFromPointer\(row\.index, row, \{\s*x: mouseArea\.mouseX,\s*y: mouseArea\.mouseY\s*\}\)/.test(launcherQml), - 'launcher samples pointer movement immediately when entering a row' -) -assert( - !/onContainsMouseChanged:[\s\S]*root\.selectedIndex/.test(launcherQml), - 'launcher does not select rows from containsMouse' -) - -const confirmDeleteMatch = launcherQml.match(/function confirmDelete\(\) \{([\s\S]*?)\n \}/) -assert(confirmDeleteMatch, 'launcher confirmDelete function exists') -assert( - confirmDeleteMatch[1].includes('root.dismiss()'), - 'launcher delete closes launcher after confirmation' -) -assert( - confirmDeleteMatch[1].includes('Util.execDetached(command)'), - 'launcher delete runs remover through the shell' -) - -const activateMatch = launcherQml.match(/function activateIndex\(index\) \{([\s\S]*?)\n \}/) -assert(activateMatch, 'launcher activateIndex function exists') -assert( - !activateMatch[1].includes('entry.execute()'), - 'launcher does not execute desktop entries directly' -) -assert( - activateMatch[1].includes('gtk-launch') && activateMatch[1].includes('Util.execDetached'), - 'launcher runs desktop entry launch through the shell' -) - -assert( - /function iconIndexScanCommand\(\)[\s\S]*-path "\*\/apps\/\*" -o -path "\*\/devices\/\*"/.test(launcherQml), - 'launcher fallback icon index includes device icons' -) - -const iconSourceMatch = launcherQml.match(/function iconSource\(icon\) \{([\s\S]*?)\n \}/) -assert(iconSourceMatch, 'launcher iconSource function exists') -assert( - iconSourceMatch[1].indexOf('root.iconIndex[value]') < iconSourceMatch[1].indexOf('Quickshell.iconPath(value, true)'), - 'launcher prefers indexed app icons over ambiguous themed icons' -) - -assert( - openMatch[1].includes('if (!iconIndexScan.running) iconIndexScan.running = true'), - 'launcher refreshes its icon index when opened' -) -JS diff --git a/test/shell.d/lid-close-test.sh b/test/shell.d/lid-close-test.sh new file mode 100755 index 00000000..746dcfc5 --- /dev/null +++ b/test/shell.d/lid-close-test.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +lid_close="$ROOT/bin/omarchy-system-lid-close" +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +# closed/docked are the two facts logind uses to decide whether a lid close +# suspends, so each scenario pins them and records what the lid handler did. +setup_scenario() { + scenario_dir="$tmpdir/$1" + mock_bin="$scenario_dir/bin" + call_log="$scenario_dir/calls" + mkdir -p "$mock_bin" + : >"$call_log" + + local closed="$2" docked="$3" + + cat >"$mock_bin/omarchy-hw-laptop-closed" <"$mock_bin/omarchy-hw-external-monitors" <"$mock_bin/$command" <>"\$CALL_LOG" +SH + done + chmod +x "$mock_bin"/* +} + +run_lid_close() { + CALL_LOG="$call_log" PATH="$mock_bin:$PATH" "$lid_close" + mapfile -t calls <"$call_log" +} + +# An undocked lid close is about to suspend, and logind's inhibitor window is a +# timer rather than a promise, so the lock has to start now instead of waiting +# for PrepareForSleep. +setup_scenario undocked 0 1 +run_lid_close + +[[ ${calls[0]} == "omarchy-system-lock" ]] || + fail "undocked lid close locks before anything else" "calls: ${calls[*]}" +pass "undocked lid close locks before anything else" + +[[ ${calls[1]} == "omarchy-hyprland-monitor-clamshell" ]] || + fail "undocked lid close still reconciles displays" "calls: ${calls[*]}" +pass "undocked lid close still reconciles displays" + +# A docked lid close is clamshell mode: logind leaves the machine awake and the +# session stays in use on the external display, so locking it would be wrong. +setup_scenario docked 0 0 +run_lid_close + +[[ ${calls[*]} != *omarchy-system-lock* ]] || + fail "docked lid close does not lock the session" "calls: ${calls[*]}" +pass "docked lid close does not lock the session" + +[[ ${calls[0]} == "omarchy-hyprland-monitor-clamshell" ]] || + fail "docked lid close reconciles displays" "calls: ${calls[*]}" +pass "docked lid close reconciles displays" + +# Hyprland can replay a switch binding when the lid is already open, and an +# open lid must never lock the machine the user is sitting at. +setup_scenario open 1 1 +run_lid_close + +[[ ${calls[*]} != *omarchy-system-lock* ]] || + fail "an open lid never locks the session" "calls: ${calls[*]}" +pass "an open lid never locks the session" + +# The lid handler runs from a Hyprland binding, so a lock that hangs or fails +# must not stop the display reconciliation behind it. +setup_scenario failing_lock 0 1 +cat >"$mock_bin/omarchy-system-lock" <<'SH' +#!/bin/bash +echo omarchy-system-lock >>"$CALL_LOG" +exit 1 +SH +chmod +x "$mock_bin/omarchy-system-lock" +run_lid_close + +[[ ${calls[1]} == "omarchy-hyprland-monitor-clamshell" ]] || + fail "a failing lock still reconciles displays" "calls: ${calls[*]}" +pass "a failing lock still reconciles displays" diff --git a/test/shell.d/lock-fingerprint-indicator-test.sh b/test/shell.d/lock-fingerprint-indicator-test.sh new file mode 100755 index 00000000..70ad7d54 --- /dev/null +++ b/test/shell.d/lock-fingerprint-indicator-test.sh @@ -0,0 +1,71 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR="" +QS_PID="" + +cleanup() { + if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then + kill "$QS_PID" 2>/dev/null || true + wait "$QS_PID" 2>/dev/null || true + fi + [[ -n $TMPDIR && -d $TMPDIR ]] && rm -rf "$TMPDIR" +} +trap cleanup EXIT + +if [[ -z ${WAYLAND_DISPLAY:-} ]]; then + pass "no Wayland compositor; skipping lock fingerprint indicator test" + exit 0 +fi + +if ! command -v quickshell >/dev/null 2>&1; then + pass "quickshell not installed; skipping lock fingerprint indicator test" + exit 0 +fi + +require_command jq + +TMPDIR=$(mktemp -d) +result="$TMPDIR/result.json" +log="$TMPDIR/quickshell.log" +config_dir="$TMPDIR/lock-fingerprint-indicator" +mkdir -p "$config_dir" "$TMPDIR/home" +cp "$SHELL_TEST_DIR/fixtures/lock-fingerprint-indicator/shell.qml" "$config_dir/shell.qml" +ln -s "$ROOT/shell/Ui" "$config_dir/Ui" +ln -s "$ROOT/shell/Commons" "$config_dir/Commons" + +OMARCHY_PATH="$ROOT" \ +OMARCHY_QML_TEST_RESULT="$result" \ +HOME="$TMPDIR/home" \ +QML2_IMPORT_PATH="$ROOT/shell${QML2_IMPORT_PATH:+:$QML2_IMPORT_PATH}" \ +QML_IMPORT_PATH="$ROOT/shell${QML_IMPORT_PATH:+:$QML_IMPORT_PATH}" \ +PATH="$ROOT/bin:$PATH" \ + quickshell -p "$config_dir" --no-color >"$log" 2>&1 & +QS_PID=$! + +for _ in {1..80}; do + [[ -s $result ]] && break + if ! kill -0 "$QS_PID" 2>/dev/null; then + sed -n '1,220p' "$log" >&2 + fail "lock fingerprint indicator quickshell exited before writing result" + fi + sleep 0.1 +done + +[[ -s $result ]] || { + sed -n '1,220p' "$log" >&2 + fail "lock fingerprint indicator test timed out" +} + +if ! jq -e '.ok == true' "$result" >/dev/null; then + printf 'Lock fingerprint indicator result:\n' >&2 + jq . "$result" >&2 + printf 'Lock fingerprint indicator log:\n' >&2 + sed -n '1,220p' "$log" >&2 + fail "fingerprint indicator tracks the configured sensor" +fi + +pass "fingerprint indicator tracks the configured sensor" diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index df36f109..1035f9b6 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -92,6 +92,8 @@ assertDeepEqual( kind: 'action', icon: '', iconFont: '', + appIcon: '', + appId: '', label: 'Theme picker', target: 'style.theme', detail: 'Style', @@ -107,6 +109,36 @@ assertDeepEqual( const defaultItems = menu.parseMenuJsonc(defaultMenuJsonc) const defaultById = Object.fromEntries(defaultItems.map(item => [item.id, item])) + +// Needs the real menu: app rows sort after all menu items, and only at that +// item count does the order tiebreak alone bury an installed app. +const rankBase = menu.mergeMenuSources(defaultItems, []) +const ranked = menu.mergeAppRows(rankBase.items, rankBase.itemOrder, [ + { id: 'apps.brave', parent: 'apps', kind: 'app', label: 'Brave', description: '', aliases: [] }, + { id: 'apps.fontforge', parent: 'apps', kind: 'app', label: 'FontForge', description: '', aliases: [] } +]) +const rankScore = (id, query) => menu.searchScore(ranked.items, ranked.items[id], query) +assert( + ['install.browser.brave', 'remove.browser.brave', 'setup.default.browser.brave'].every( + id => rankScore('apps.brave', 'brave') < rankScore(id, 'brave') + ), + 'menu ranks an installed app above menu entries matching the query equally well' +) +assert( + rankScore('style.font', 'font') < rankScore('apps.fontforge', 'font'), + 'menu keeps a better-matching menu entry above a weaker app match' +) +const triggerItems = defaultItems.filter(item => item.parent === 'trigger') +assertEqual( + triggerItems[0].id, + 'trigger.emoji', + 'menu lists Emoji first under Trigger' +) +assertEqual( + defaultById['trigger.emoji'].action, + 'omarchy-menu-emoji', + 'menu opens the emoji picker from Trigger' +) assert( defaultById['update.omarchy'].icon === '\ue900', 'menu update Omarchy entry uses the Omarchy glyph' @@ -120,7 +152,7 @@ assert( 'menu keeps Input as a direct config action' ) assert( - defaultById['setup.direct-boot'].action.includes('omarchy-config-direct-boot'), + defaultById['setup.direct-boot'].action.includes('omarchy-setup-direct-boot'), 'menu places Direct Boot directly under Setup' ) assertEqual( @@ -159,6 +191,11 @@ assertEqual( 'omarchy-hw-laptop', 'menu only shows Mirror Display on laptops' ) +assertEqual( + defaultById['trigger.capture.screenrecord.webcam'].when, + 'omarchy-hw-webcam', + 'menu only shows webcam screen recording when a webcam is available' +) assert( /font\.family: row\.iconFont\.length > 0 \? row\.iconFont : root\.fontFamily/.test(menuQml), 'menu rows support per-icon font families' @@ -188,6 +225,88 @@ assert( /function disarmPointer\(\)[\s\S]*pointerGate\.reset\(\)/.test(menuQml), 'menu resets pointer movement gate when pointer selection is disarmed' ) +// App rows are rebuilt from scratch on every desktop-entry rescan. The merge +// must be idempotent and must never carry an orphan id forward, or a single +// lost write turns into an app listed twice (and thrice, and so on). +const nonAppItems = { + root: { id: 'root', kind: 'menu', label: 'Go' }, + apps: { id: 'apps', kind: 'menu', label: 'Apps', provider: 'apps' } +} +const nonAppOrder = ['root', 'apps'] +const appRowsFor = ids => ids.map(id => ({ id: `apps.${id}`, kind: 'app', parent: 'apps', label: id, appId: id })) + +const firstMerge = menu.mergeAppRows(nonAppItems, nonAppOrder, appRowsFor(['alacritty', 'youtube'])) +assert( + firstMerge.itemOrder.join(',') === 'root,apps,apps.alacritty,apps.youtube', + 'app merge appends app rows after the static menu items' +) + +const secondMerge = menu.mergeAppRows(firstMerge.items, firstMerge.itemOrder, appRowsFor(['alacritty', 'youtube'])) +assert( + secondMerge.itemOrder.join(',') === 'root,apps,apps.alacritty,apps.youtube', + 'repeating the app merge with the same entries does not duplicate rows' +) + +assert( + menu.mergeAppRows(secondMerge.items, secondMerge.itemOrder, appRowsFor(['alacritty'])).itemOrder.join(',') + === 'root,apps,apps.alacritty', + 'app merge drops rows for entries that went away' +) + +assert( + menu.mergeAppRows(nonAppItems, nonAppOrder, appRowsFor(['youtube', 'youtube'])).itemOrder.join(',') + === 'root,apps,apps.youtube', + 'app merge lists an app once even when two desktop entries share an id' +) + +const orphanedItems = {} +for (const key in firstMerge.items) orphanedItems[key] = firstMerge.items[key] +delete orphanedItems['apps.youtube'] +const healed = menu.mergeAppRows(orphanedItems, firstMerge.itemOrder, appRowsFor(['alacritty', 'youtube'])) +assert( + healed.itemOrder.join(',') === 'root,apps,apps.alacritty,apps.youtube' + && !!healed.items['apps.youtube'], + 'app merge heals an order entry whose item went missing instead of duplicating it' +) + +assert( + !firstMerge.items['apps.youtube'].hasOwnProperty('__probe') + && (() => { + const before = Object.keys(nonAppItems).length + menu.mergeAppRows(nonAppItems, nonAppOrder, appRowsFor(['gimp'])) + return Object.keys(nonAppItems).length === before + })(), + 'app merge leaves the map it was handed untouched' +) + +const providerRowsFor = values => values.map(value => ({ id: `style.font.${value}`, kind: 'action', parent: 'style.font', label: value })) +const firstProviderMerge = menu.mergeRowsById(nonAppItems, nonAppOrder, providerRowsFor(['mono', 'serif'])) +assert( + firstProviderMerge.itemOrder.join(',') === 'root,apps,style.font.mono,style.font.serif', + 'provider merge appends its rows' +) +assert( + menu.mergeRowsById(firstProviderMerge.items, firstProviderMerge.itemOrder, providerRowsFor(['mono', 'serif'])) + .itemOrder.join(',') === 'root,apps,style.font.mono,style.font.serif', + 'repeating a provider merge does not duplicate rows' +) + +// The maps live in QML `var` properties, where an in-place write is +// occasionally dropped by the engine, so both merges must hand back fresh +// objects for the caller to assign in one shot. +assert( + /var merged = MenuModel\.mergeAppRows\(root\.items, root\.itemOrder, appRows\)\s*\n\s*root\.items = merged\.items\s*\n\s*root\.itemOrder = merged\.itemOrder/.test(menuQml), + 'menu assigns the rebuilt app item map instead of mutating it in place' +) +assert( + /var merged = MenuModel\.mergeRowsById\(root\.items, root\.itemOrder, providerRows\)\s*\n\s*root\.items = merged\.items\s*\n\s*root\.itemOrder = merged\.itemOrder/.test(menuQml), + 'menu assigns the rebuilt provider item map instead of mutating it in place' +) +assert( + !/root\.items\[[^\]]+\] =/.test(menuQml) && !/delete root\.items\[/.test(menuQml), + 'menu never writes into the item map held by the var property' +) + for (const functionName of ['openExistingMenu', 'openDmenu']) { const openMatch = menuQml.match(new RegExp(`function ${functionName}\\([^)]*\\) \\{([\\s\\S]*?)\\n \\}`)) assert(openMatch, `menu ${functionName} function exists`) diff --git a/test/shell.d/migrate-notify-test.sh b/test/shell.d/migrate-notify-test.sh index 9b182acb..a07888e9 100644 --- a/test/shell.d/migrate-notify-test.sh +++ b/test/shell.d/migrate-notify-test.sh @@ -33,6 +33,12 @@ bash -c "$command" SH chmod +x "$stub_bin/systemd-run" +cat >"$stub_bin/omarchy-notification-wait" <<'SH' +#!/bin/bash +exit 0 +SH +chmod +x "$stub_bin/omarchy-notification-wait" + cat >"$stub_bin/omarchy-notification-send" <<'SH' #!/bin/bash printf '%s\n' "$@" >"$OMARCHY_TEST_NOTIFY_ARGS" diff --git a/test/shell.d/monitor-clamshell-scale-test.sh b/test/shell.d/monitor-clamshell-scale-test.sh index c9ee26ef..7bd7a554 100644 --- a/test/shell.d/monitor-clamshell-scale-test.sh +++ b/test/shell.d/monitor-clamshell-scale-test.sh @@ -65,6 +65,13 @@ local omarchy_monitor_scale = "auto" LUA } +write_internal_monitor_config() { + cat >"$monitor_lua" <<'LUA' +hl.monitor({ output = "eDP-1", mode = "preferred", position = "0x0", scale = 1.25 }) +hl.monitor({ output = "", mode = "preferred", position = "auto-right", scale = 1 }) +LUA +} + run_clamshell() { HOME="$home_dir" \ PATH="$stub_bin:$PATH" \ @@ -102,3 +109,10 @@ OMARCHY_TEST_INTERNAL_DISABLED=true run_clamshell grep -F 'scale = 1.6' "$eval_log" >/dev/null || fail "clamshell recovery uses remembered internal scale" ! grep -F 'scale = "auto"' "$eval_log" >/dev/null || fail "clamshell recovery avoids auto after disabled internal display" pass "clamshell recovery uses remembered internal scale" + +write_internal_monitor_config +: >"$eval_log" +OMARCHY_TEST_INTERNAL_DISABLED=true run_clamshell +grep -F 'position = "0x0"' "$eval_log" >/dev/null || fail "clamshell recovery uses configured internal position" +grep -F 'scale = 1.25' "$eval_log" >/dev/null || fail "clamshell recovery uses configured internal scale" +pass "clamshell recovery uses configured internal monitor rule" diff --git a/test/shell.d/monitor-recovery-test.sh b/test/shell.d/monitor-recovery-test.sh index 18cab29b..ec5c1ae5 100755 --- a/test/shell.d/monitor-recovery-test.sh +++ b/test/shell.d/monitor-recovery-test.sh @@ -7,12 +7,13 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" monitor_watch="$ROOT/bin/omarchy-hyprland-monitor-watch" monitor_internal="$ROOT/bin/omarchy-hyprland-monitor-internal" monitor_mirror="$ROOT/bin/omarchy-hyprland-monitor-internal-mirror" +monitor_laptop="$ROOT/bin/omarchy-hyprland-monitor-laptop" monitor_external_active="$ROOT/bin/omarchy-hyprland-monitor-external-active" -sleep_lock="$ROOT/bin/omarchy-system-sleep-lock" system_wake="$ROOT/bin/omarchy-system-wake" clamshell="$ROOT/bin/omarchy-hyprland-monitor-clamshell" lock_service="$ROOT/shell/plugins/lock/Service.qml" hw_clamshell="$ROOT/bin/omarchy-hw-clamshell" +hw_laptop_closed="$ROOT/bin/omarchy-hw-laptop-closed" utilities="$ROOT/default/hypr/bindings/utilities.lua" grep -F 'sleep "$delay"' "$monitor_watch" >/dev/null @@ -35,8 +36,8 @@ grep -F 'sync_poll_state' "$monitor_watch" >/dev/null grep -F 'done < <(socat' "$monitor_watch" >/dev/null pass "clamshell poll only runs on a docked laptop, not desktops or undocked laptops" -grep -F '/proc/acpi/button/lid/*/state' "$hw_clamshell" >/dev/null -grep -F 'omarchy-hw-external-monitors' "$hw_clamshell" >/dev/null +grep -F 'omarchy-hw-laptop-closed && omarchy-hw-external-monitors' "$hw_clamshell" >/dev/null +grep -F '/proc/acpi/button/lid/*/state' "$hw_laptop_closed" >/dev/null pass "clamshell helper detects closed-lid external monitor state" grep -F 'hyprctl monitors -j' "$monitor_external_active" >/dev/null @@ -60,7 +61,8 @@ grep -F 'omarchy-hw-clamshell' "$clamshell" >/dev/null pass "clamshell monitor sync disables laptop output and force-recovers it" grep -F "hyprctl dispatch 'hl.dsp.dpms({ action = \"enable\" })' >/dev/null 2>&1 || true" "$monitor_internal" >/dev/null -grep -F 'hyprctl monitors all -j' "$monitor_internal" >/dev/null +grep -F 'omarchy-hyprland-monitor-laptop' "$monitor_internal" >/dev/null +grep -F 'hyprctl monitors all -j' "$monitor_laptop" >/dev/null grep -F 'omarchy-hyprland-monitor-external-active' "$monitor_internal" >/dev/null grep -F 'wake' "$monitor_internal" >/dev/null grep -F 'omarchy-hyprland-toggle-enabled $TOGGLE || return 0' "$monitor_internal" >/dev/null @@ -70,13 +72,9 @@ pass "internal monitor recovery only wakes displays when it re-enables one" grep -F 'omarchy-hyprland-monitor-external-active' "$monitor_mirror" >/dev/null pass "internal mirror helper recovers when no active external display remains" -grep -F 'switch:on:Lid Switch", nil, "omarchy-hyprland-monitor-clamshell"' "$utilities" >/dev/null +grep -F 'switch:on:Lid Switch", nil, "omarchy-system-lid-close"' "$utilities" >/dev/null grep -F 'switch:off:Lid Switch", nil, "omarchy-hyprland-monitor-clamshell"' "$utilities" >/dev/null -pass "lid switch bindings reconcile clamshell display state" - -grep -F 'omarchy-hyprland-monitor-clamshell >/dev/null 2>&1 || true' "$sleep_lock" >/dev/null -grep -F '(( attempt % 5 == 0 )) && sync_clamshell' "$sleep_lock" >/dev/null -pass "sleep lock syncs clamshell display state while waiting for secure lock" +pass "lid switch bindings lock on close and reconcile clamshell display state" grep -F 'omarchy-hyprland-monitor-clamshell >/dev/null 2>&1 || true' "$system_wake" >/dev/null pass "system wake resyncs clamshell display state" diff --git a/test/shell.d/monitor-scaling-test.sh b/test/shell.d/monitor-scaling-test.sh index 01887c62..4a0519cf 100644 --- a/test/shell.d/monitor-scaling-test.sh +++ b/test/shell.d/monitor-scaling-test.sh @@ -74,14 +74,42 @@ scale=$(OMARCHY_TEST_MONITOR_SCALE=3 run_scaling) [[ $scale == "3" ]] || fail "monitor scaling reports explicit 3x scale" "actual: $scale" pass "monitor scaling reports explicit 3x scale" -# 1280x800 (QEMU virtio-gpu) can't divide cleanly by 3; expect a snap up to 3.2. +scale=$(OMARCHY_TEST_MONITOR_SCALE=3.2 run_scaling) +[[ $scale == "3.2" ]] || fail "monitor scaling reports the actual non-preset scale" "actual: $scale" +pass "monitor scaling reports the actual non-preset scale" + +# 1280x800 approximates the 3x preset as 3.2x. write_monitor_config OMARCHY_TEST_MONITOR_SCALE=2 OMARCHY_TEST_MONITOR_WIDTH=1280 OMARCHY_TEST_MONITOR_HEIGHT=800 run_scaling 3 -grep -F 'scale = 3.2' "$eval_out" >/dev/null || fail "monitor scaling snaps unclean 3x up to 3.2x" -grep -Fx 'local omarchy_monitor_scale = 3.2' "$monitor_lua" >/dev/null || fail "monitor scaling persists snapped 3.2x" -pass "monitor scaling snaps unclean 3x up to 3.2x" +grep -F 'scale = 3.2' "$eval_out" >/dev/null || fail "monitor scaling approximates explicit 3x as 3.2x" +grep -Fx 'local omarchy_monitor_scale = 3.2' "$monitor_lua" >/dev/null || + fail "monitor scaling persists approximated 3.2x" +pass "monitor scaling approximates explicit 3x as 3.2x" write_monitor_config OMARCHY_TEST_MONITOR_SCALE=2 OMARCHY_TEST_MONITOR_WIDTH=1280 OMARCHY_TEST_MONITOR_HEIGHT=800 run_scaling up -grep -F 'scale = 3.2' "$eval_out" >/dev/null || fail "monitor scaling up snaps unclean preset to 3.2x" -pass "monitor scaling up snaps unclean preset to 3.2x" +grep -F 'scale = 3.2' "$eval_out" >/dev/null || fail "monitor scaling up reaches approximated 3.2x" +pass "monitor scaling up reaches approximated 3.2x" + +write_monitor_config +OMARCHY_TEST_MONITOR_SCALE=4 OMARCHY_TEST_MONITOR_WIDTH=1280 OMARCHY_TEST_MONITOR_HEIGHT=800 run_scaling down +grep -F 'scale = 3.2' "$eval_out" >/dev/null || fail "monitor scaling down reaches approximated 3.2x" +pass "monitor scaling down reaches approximated 3.2x" + +write_monitor_config +OMARCHY_TEST_MONITOR_SCALE=2 OMARCHY_TEST_MONITOR_WIDTH=6016 OMARCHY_TEST_MONITOR_HEIGHT=3384 run_scaling 1.25 +grep -F 'scale = 1.33333' "$eval_out" >/dev/null || fail "monitor scaling approximates explicit 1.25x" +pass "monitor scaling approximates explicit 1.25x" + +write_monitor_config +OMARCHY_TEST_MONITOR_SCALE=2 OMARCHY_TEST_MONITOR_WIDTH=1280 OMARCHY_TEST_MONITOR_HEIGHT=800 run_scaling 3.2 +grep -F 'scale = 3.2' "$eval_out" >/dev/null || fail "monitor scaling accepts displayed approximate values" +pass "monitor scaling accepts displayed approximate values" + +# On a mode where both 3x and 4x resolve to 4x, the duplicate is one step. +write_monitor_config +OMARCHY_TEST_MONITOR_SCALE=4 OMARCHY_TEST_MONITOR_WIDTH=1280 OMARCHY_TEST_MONITOR_HEIGHT=804 run_scaling down +grep -F 'scale = 2' "$eval_out" >/dev/null || fail "monitor scaling down skips duplicate 4x approximation" +grep -Fx 'local omarchy_monitor_scale = 2' "$monitor_lua" >/dev/null || + fail "monitor scaling down persists 2x after skipping duplicate approximation" +pass "monitor scaling down skips duplicate approximation" diff --git a/test/shell.d/monitor-test.sh b/test/shell.d/monitor-test.sh index aa4ddac7..30014b22 100644 --- a/test/shell.d/monitor-test.sh +++ b/test/shell.d/monitor-test.sh @@ -14,21 +14,60 @@ assertEqual(monitor.clampBrightness('nope'), 1, 'monitor rejects invalid brightn assertEqual(monitor.normalizeScale('1.250'), '1.25', 'monitor normalizes fractional scale') assertEqual(monitor.normalizeScale('nope'), '', 'monitor rejects invalid scale') +assertEqual(monitor.cleanScale(3, 1280, 800), '3.2', 'monitor matches clean VM scale') +assertEqual(monitor.cleanScale(1.25, 1280, 800), '1.25', 'monitor preserves an already clean scale') +assertEqual(monitor.cleanScale(1.25, 6016, 3384), '1.33', 'monitor matches clean physical display scale') +assertEqual(monitor.cleanScale(1.6, 0, 800), '', 'monitor rejects a missing display mode') +assertEqual( + monitor.matchingScaleIndex(['1', '1.25', '1.6', '2', '3', '4'], 3.2, 1280, 800), + 4, + 'monitor selects an approximated VM scale' +) +assertEqual( + monitor.matchingScaleIndex(['1', '1.25', '1.6', '2', '3', '4'], 4, 4, 4), + 5, + 'monitor selects an exact preset' +) +assertDeepEqual( + monitor.availableScales(['1', '1.25', '1.6', '2', '3', '4'], 1280, 800), + ['1', '1.25', '1.6', '2', '3', '4'], + 'monitor keeps distinct approximated VM scales' +) +assertDeepEqual( + monitor.availableScales(['1', '1.25', '1.6', '2', '3', '4'], 6016, 3384), + ['1', '1.25', '1.6', '2', '3', '4'], + 'monitor keeps distinct approximated physical display scales' +) +assertDeepEqual( + monitor.availableScales(['1', '1.25', '1.6', '2', '3', '4'], 1280, 804), + ['1', '1.25', '2', '4'], + 'monitor collapses presets with duplicate effective scales' +) +assertDeepEqual( + monitor.availableScales(['1', '1.25', '1.6', '2', '3', '4'], 5968, 3230), + ['1', '2'], + 'monitor hides presets the current mode cannot reach' +) +assertDeepEqual( + monitor.availableScales(['1', '1.25', '1.6', '2', '3', '4'], 0, 0), + ['1', '1.25', '1.6', '2', '3', '4'], + 'monitor keeps presets until display dimensions are known' +) assertEqual(monitor.brightnessName(96), 'Sun blast', 'monitor names very bright displays') assertEqual(monitor.brightnessName(12), 'Candlelit', 'monitor names dim displays') assertDeepEqual( monitor.parseDisplays(JSON.stringify([ - { name: 'eDP-1', enabled: true }, - { name: 'HDMI-A-1', enabled: false }, - { name: 'DP-1', enabled: true } + { name: 'eDP-1', enabled: true, focused: false, width: 1920, height: 1080 }, + { name: 'HDMI-A-1', enabled: false, focused: false, width: 0, height: 0 }, + { name: 'DP-1', enabled: true, focused: true, width: 1280, height: 800 } ])), { displays: [ - { name: 'eDP-1', enabled: true }, - { name: 'HDMI-A-1', enabled: false }, - { name: 'DP-1', enabled: true } + { name: 'eDP-1', enabled: true, focused: false, width: 1920, height: 1080 }, + { name: 'HDMI-A-1', enabled: false, focused: false, width: 0, height: 0 }, + { name: 'DP-1', enabled: true, focused: true, width: 1280, height: 800 } ], enabledDisplayCount: 2 }, diff --git a/test/shell.d/notifications-test.sh b/test/shell.d/notifications-test.sh index 6f0a852a..27651836 100644 --- a/test/shell.d/notifications-test.sh +++ b/test/shell.d/notifications-test.sh @@ -188,7 +188,6 @@ assertEqual(notifications.imageExtension('/tmp/no-extension'), 'png', 'notificat assertEqual(notifications.imageExtension('/tmp/archive.reallylong'), 'png', 'notifications reject suspicious image extensions') const serviceQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/Service.qml'), 'utf8') -const barWidgetQml = fs.readFileSync(path.join(root, 'shell/plugins/notifications/BarWidget.qml'), 'utf8') assert( /readonly property int historyReplayLimit: 5/.test(serviceQml), 'notifications service limits history replay to five rows' @@ -197,8 +196,4 @@ assert( /function showHistory\(\): string \{\s*return service\.showRecentHistory\(\)\s*\}/.test(serviceQml), 'notifications history IPC replays recent notifications' ) -assert( - !barWidgetQml.includes('historyOpenRequested'), - 'notifications history IPC does not depend on the bar widget' -) JS diff --git a/test/shell.d/osd-test.sh b/test/shell.d/osd-test.sh index d8d1587d..033f085e 100644 --- a/test/shell.d/osd-test.sh +++ b/test/shell.d/osd-test.sh @@ -11,6 +11,7 @@ assertEqual(osd.iconFor('', 0), osd.iconFor('muted', 50), 'osd falls back to mut assertEqual(osd.iconFor('volume-high', 1), osd.iconFor('', 100), 'osd maps high volume aliases') assertEqual(osd.iconFor('logout', 50), '󰍃', 'osd maps logout icon') assertEqual(osd.iconFor('custom-symbol', 50), 'custom-symbol', 'osd preserves unknown explicit icons') +assertEqual(osd.widestIcon, osd.iconFor('volume-high', 100), 'osd sizes the icon column to a glyph it can show') assertDeepEqual( osd.stateForShow('volume', '', '75', '100', '', '800'), @@ -21,8 +22,7 @@ assertDeepEqual( value: 75, message: '75%', icon: osd.iconFor('volume', 75), - duration: 800, - fit: false + duration: 800 }, 'osd builds progress state' ) @@ -36,24 +36,8 @@ assertDeepEqual( value: 0, message: 'Paused', icon: osd.iconFor('media-pause', -1), - duration: 1200, - fit: false + duration: 1200 }, 'osd builds message state' ) - -assertDeepEqual( - osd.stateForShow('shutdown', 'Shutting down…', '', '100', '', '5000', '1'), - { - iconKey: 'shutdown', - maxValue: 100, - hasProgress: false, - value: 0, - message: 'Shutting down…', - icon: osd.iconFor('shutdown', -1), - duration: 5000, - fit: true - }, - 'osd parses fit flag' -) JS diff --git a/test/shell.d/polkit-test.sh b/test/shell.d/polkit-test.sh index 2683e2b5..5283589d 100644 --- a/test/shell.d/polkit-test.sh +++ b/test/shell.d/polkit-test.sh @@ -23,19 +23,27 @@ assertEqual( ) assert( - polkit.fingerprintFirstFromPamConfig(` + polkit.fingerprintConfiguredFromPamConfig(` # comment auth sufficient pam_fprintd.so auth include system-auth `), - 'polkit detects fingerprint-first PAM config' + 'polkit detects fingerprint in a PAM config' ) assert( - !polkit.fingerprintFirstFromPamConfig(` + polkit.fingerprintConfiguredFromPamConfig(` +auth [success=1 default=ignore] pam_exec.so quiet /usr/bin/omarchy-hw-laptop-closed +auth sufficient pam_fprintd.so +auth required pam_unix.so +`), + 'polkit detects fingerprint even behind a clamshell gate' +) +assert( + !polkit.fingerprintConfiguredFromPamConfig(` account include system-auth auth include system-auth -auth sufficient pam_fprintd.so +auth required pam_unix.so `), - 'polkit detects password-first PAM config' + 'polkit reports no fingerprint when pam_fprintd is absent' ) JS diff --git a/test/shell.d/restart-shell-test.sh b/test/shell.d/restart-shell-test.sh index 727e95ed..f7d579ff 100755 --- a/test/shell.d/restart-shell-test.sh +++ b/test/shell.d/restart-shell-test.sh @@ -70,7 +70,9 @@ printf '%s\n' "$*" >>"$OMARCHY_TEST_IPC_LOG" case "$*" in *'shell ping') - grep -Fx '303' "$OMARCHY_TEST_QS_STATE" >/dev/null && printf 'ok\n' + [[ $* == *"-p $OMARCHY_TEST_SESSION_PATH/shell"* ]] && + grep -Fx '303' "$OMARCHY_TEST_QS_STATE" >/dev/null && + printf 'ok\n' ;; esac SH @@ -107,14 +109,27 @@ if [[ ${1:-} == "-j" && ${2:-} == "monitors" ]]; then fi elif [[ ${1:-} == "dispatch" && ${2:-} == hl.dsp.exec_cmd* ]]; then printf '%s\n' "${2:-}" >>"$OMARCHY_TEST_DISPATCH_LOG" - env -u OMARCHY_TEST_TRANSIENT_ENV quickshell -n -p "$OMARCHY_PATH/shell" + OMARCHY_PATH="$OMARCHY_TEST_SESSION_PATH" \ + env -u OMARCHY_TEST_TRANSIENT_ENV quickshell -n -p "$OMARCHY_TEST_SESSION_PATH/shell" printf 'ok\n' elif [[ ${1:-} == "dispatch" ]]; then exit 1 fi SH -chmod +x "$restart_bin/qs" "$restart_bin/quickshell" "$restart_bin/hyprctl" +cat >"$restart_bin/systemctl" <<'SH' +#!/bin/bash + +if [[ ${1:-} == "--user" && ${2:-} == "show-environment" ]]; then + printf 'OMARCHY_PATH=%s\n' "$OMARCHY_TEST_SESSION_PATH" +elif [[ ${1:-} == "--user" && ${2:-} == "try-restart" ]]; then + exit 0 +else + exit 1 +fi +SH + +chmod +x "$restart_bin/qs" "$restart_bin/quickshell" "$restart_bin/hyprctl" "$restart_bin/systemctl" sleep 30 & restart_pid_one=$! @@ -122,14 +137,19 @@ sleep 30 & restart_pid_two=$! printf '%s\n%s\n' "$restart_pid_one" "$restart_pid_two" >"$restart_state" +caller_root="$test_tmp/caller-root" +mkdir -p "$caller_root/shell" +touch "$caller_root/shell/shell.qml" + PATH="$restart_bin:$PATH" \ -OMARCHY_PATH="$restart_root" \ +OMARCHY_PATH="$caller_root" \ XDG_RUNTIME_DIR="$runtime_dir" \ OMARCHY_TEST_QS_STATE="$restart_state" \ OMARCHY_TEST_QS_LOG="$restart_log" \ OMARCHY_TEST_QS_ENV_LOG="$restart_env_log" \ OMARCHY_TEST_DISPATCH_LOG="$dispatch_log" \ OMARCHY_TEST_IPC_LOG="$ipc_log" \ +OMARCHY_TEST_SESSION_PATH="$restart_root" \ OMARCHY_TEST_TRANSIENT_ENV=leaked \ timeout 5 "$ROOT/bin/omarchy-restart-shell" @@ -145,10 +165,11 @@ restart_pid_one="" restart_pid_two="" [[ $(<"$restart_state") == 303 ]] || fail "restart leaves exactly one fresh shell instance" [[ $(grep -c '^-n -p ' "$restart_log") == 1 ]] || fail "restart launches one fresh shell process" +grep -F "kill -p $restart_root/shell --any-display" "$restart_log" >/dev/null || fail "restart stops the shell from the session checkout" [[ $(<"$restart_env_log") == "unset" ]] || fail "restart uses the Hyprland session environment for the fresh shell" grep -F 'hl.dsp.exec_cmd("quickshell -n -p $OMARCHY_PATH/shell")' "$dispatch_log" >/dev/null || fail "restart launches the fresh shell through Hyprland" -grep -F 'shell ping' "$ipc_log" >/dev/null || fail "restart waits for fresh shell IPC readiness" -pass "restart replaces duplicate shell instances" +grep -F "ipc -n -p $restart_root/shell call -- shell ping" "$ipc_log" >/dev/null || fail "restart checks readiness in the session checkout" +pass "restart replaces duplicate shell instances from the session checkout" : >"$restart_log" printf '404\n' >"$restart_state" @@ -161,6 +182,7 @@ locked_error=$(PATH="$restart_bin:$PATH" \ OMARCHY_TEST_QS_LOG="$restart_log" \ OMARCHY_TEST_DISPATCH_LOG="$dispatch_log" \ OMARCHY_TEST_IPC_LOG="$ipc_log" \ + OMARCHY_TEST_SESSION_PATH="$restart_root" \ "$ROOT/bin/omarchy-restart-shell" 2>&1) && fail "restart refuses while the shell lock is active" [[ $locked_error == "Refusing to restart Omarchy shell while the session is locked." ]] || fail "locked restart explains why it was refused" "$locked_error" diff --git a/test/shell.d/row-border-stability-test.sh b/test/shell.d/row-border-stability-test.sh index 35df4c9c..7ba87a8e 100755 --- a/test/shell.d/row-border-stability-test.sh +++ b/test/shell.d/row-border-stability-test.sh @@ -5,7 +5,6 @@ run_node_test <<'JS' const fs = require('fs') const menuQml = fs.readFileSync(path.join(root, 'shell/plugins/menu/Menu.qml'), 'utf8') -const launcherQml = fs.readFileSync(path.join(root, 'shell/plugins/launcher/Launcher.qml'), 'utf8') assert( /rowReservedBorderLeft:\s*Border\.left\(selectedBorderSpec\)/.test(menuQml) @@ -18,14 +17,4 @@ assert( 'Menu row content does not depend on current selected border state' ) -assert( - /rowReservedBorderLeft:\s*Border\.left\(selectedBorderSpec\)/.test(launcherQml) - && /rowReservedBorderRight:\s*Border\.right\(selectedBorderSpec\)/.test(launcherQml), - 'Launcher rows reserve selected border insets' -) - -assert( - !/anchors\.(left|right)Margin:[^\n]*\brow\.border(Left|Right)\b/.test(launcherQml), - 'Launcher row content does not depend on current selected border state' -) JS diff --git a/test/shell.d/runtime-smoke-test.sh b/test/shell.d/runtime-smoke-test.sh index c9ed6432..fc4a8c1f 100755 --- a/test/shell.d/runtime-smoke-test.sh +++ b/test/shell.d/runtime-smoke-test.sh @@ -127,8 +127,8 @@ jq -e ' } pass "shell IPC returns effective shell config" -[[ $(shell_ipc shell summon omarchy.launcher '{"query":"term"}') == "ok" ]] || fail_with_log "shell IPC summons launcher overlay" -shell_ipc_quiet shell hide omarchy.launcher >/dev/null +[[ $(shell_ipc shell summon omarchy.menu '{"menu":"apps"}') == "ok" ]] || fail_with_log "shell IPC summons menu apps overlay" +shell_ipc_quiet shell hide omarchy.menu >/dev/null [[ $(shell_ipc shell summon missing.plugin "{}") == "unknown" ]] || fail_with_log "shell IPC rejects unknown plugin" pass "shell IPC summon and hide contract works" diff --git a/test/shell.d/screenrecording-test.sh b/test/shell.d/screenrecording-test.sh index 2abadec2..cb8ca7e1 100644 --- a/test/shell.d/screenrecording-test.sh +++ b/test/shell.d/screenrecording-test.sh @@ -13,6 +13,8 @@ mkdir -p "$stub_bin" cat >"$stub_bin/v4l2-ctl" <<'SH' #!/bin/bash +[[ ${OMARCHY_TEST_NO_WEBCAM:-false} == "true" ]] && exit 0 + printf '%s\n' "Built-in Webcam: Integrated Camera" printf '\t%s\n' "/dev/video0" printf '\t%s\n' "/dev/video1" @@ -43,10 +45,24 @@ SH chmod +x "$stub_bin"/* export PATH="$stub_bin:$ROOT/bin:$PATH" +# The resize helper anchors to a region file here, so keep it out of the real one +export XDG_RUNTIME_DIR="$tmp_dir" export OMARCHY_TEST_MENU_ARGS="$tmp_dir/menu-args" export OMARCHY_TEST_RECORDER_ARGS="$tmp_dir/recorder-args" export OMARCHY_TEST_NOTIFICATION_ARGS="$tmp_dir/notification-args" +if "$ROOT/bin/omarchy-hw-webcam"; then + pass "webcam hardware detection succeeds when a video device is available" +else + fail "webcam hardware detection succeeds when a video device is available" +fi + +if OMARCHY_TEST_NO_WEBCAM=true "$ROOT/bin/omarchy-hw-webcam"; then + fail "webcam hardware detection fails when no video device is available" +else + pass "webcam hardware detection fails when no video device is available" +fi + "$ROOT/bin/omarchy-capture-screenrecording-with-webcam" expected_menu_args="$tmp_dir/expected-menu-args" @@ -139,6 +155,59 @@ if [[ -s $OMARCHY_TEST_HYPRCTL_ARGS ]]; then fi pass "webcam resize ignores other windows" +region_file="$XDG_RUNTIME_DIR/omarchy-screenrecord-region" + +: >"$OMARCHY_TEST_HYPRCTL_ARGS" +echo "800x600+100+100" >"$region_file" +"$ROOT/bin/omarchy-capture-webcam-resize" reset + +printf '%s\n' \ + 'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 133, y = 150 })' \ + 'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 727, y = 510 })' >"$expected_hyprctl_args" + +if ! cmp -s "$OMARCHY_TEST_HYPRCTL_ARGS" "$expected_hyprctl_args"; then + fail "webcam anchors to the recorded region" "$(diff -u "$expected_hyprctl_args" "$OMARCHY_TEST_HYPRCTL_ARGS")" +fi +pass "webcam anchors to the recorded region" + +printf '%s\n' \ + 'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 178, y = 200 })' \ + 'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 2342, y = 460 })' >"$expected_hyprctl_args" + +for region in "not-a-region" ""; do + : >"$OMARCHY_TEST_HYPRCTL_ARGS" + printf '%s' "$region" >"$region_file" + "$ROOT/bin/omarchy-capture-webcam-resize" reset + + if ! cmp -s "$OMARCHY_TEST_HYPRCTL_ARGS" "$expected_hyprctl_args"; then + fail "webcam falls back to the monitor for an unusable region" "$(diff -u "$expected_hyprctl_args" "$OMARCHY_TEST_HYPRCTL_ARGS")" + fi +done +pass "webcam falls back to the monitor for an unusable region" + +# A region too narrow for presets scaled from its height shrinks the whole +# ladder, so the three sizes stay distinct and each one fits inside the margins +: >"$OMARCHY_TEST_HYPRCTL_ARGS" +echo "200x1200+0+0" >"$region_file" +for size in small medium large; do + "$ROOT/bin/omarchy-capture-webcam-resize" "$size" +done + +printf '%s\n' \ + 'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 64, y = 72 })' \ + 'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 96, y = 1088 })' \ + 'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 89, y = 100 })' \ + 'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 71, y = 1060 })' \ + 'dispatch hl.dsp.window.resize({ window = "address:0xabc", x = 120, y = 135 })' \ + 'dispatch hl.dsp.window.move({ window = "address:0xabc", x = 40, y = 1025 })' >"$expected_hyprctl_args" + +if ! cmp -s "$OMARCHY_TEST_HYPRCTL_ARGS" "$expected_hyprctl_args"; then + fail "webcam sizes stay distinct and inside a narrow region" "$(diff -u "$expected_hyprctl_args" "$OMARCHY_TEST_HYPRCTL_ARGS")" +fi +pass "webcam sizes stay distinct and inside a narrow region" + +rm -f "$region_file" + grep -F 'o.bind("SUPER + ALT + code:34", "Make webcam overlay smaller", "omarchy-capture-webcam-resize smaller")' \ "$ROOT/default/hypr/bindings/utilities.lua" >/dev/null || fail "webcam smaller hotkey is configured" grep -F 'o.bind("SUPER + ALT + code:35", "Make webcam overlay larger", "omarchy-capture-webcam-resize larger")' \ diff --git a/test/shell.d/shell-ipc-display-test.sh b/test/shell.d/shell-ipc-display-test.sh new file mode 100755 index 00000000..57327a75 --- /dev/null +++ b/test/shell.d/shell-ipc-display-test.sh @@ -0,0 +1,30 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +mkdir -p "$test_dir/bin" "$test_dir/run" +touch "$test_dir/run/wayland-1" "$test_dir/run/wayland-1.lock" + +cat >"$test_dir/bin/qs" <<'STUB' +#!/bin/bash +echo "display=[$WAYLAND_DISPLAY]" +STUB +chmod +x "$test_dir/bin/qs" + +export PATH="$test_dir/bin:$PATH" +export OMARCHY_PATH="$ROOT" +export XDG_RUNTIME_DIR="$test_dir/run" + +# tmux hooks run without WAYLAND_DISPLAY, and qs matches instances by display. +output=$(env -u WAYLAND_DISPLAY "$ROOT/bin/omarchy-shell" omarchy.indicators refresh) +[[ $output == "display=[wayland-1]" ]] || fail "shell ipc recovers a missing display" "$output" +pass "shell ipc recovers a missing display" + +output=$(WAYLAND_DISPLAY=wayland-9 "$ROOT/bin/omarchy-shell" omarchy.indicators refresh) +[[ $output == "display=[wayland-9]" ]] || fail "shell ipc keeps an existing display" "$output" +pass "shell ipc keeps an existing display" diff --git a/test/shell.d/sleep-lock-test.sh b/test/shell.d/sleep-lock-test.sh new file mode 100755 index 00000000..c9385946 --- /dev/null +++ b/test/shell.d/sleep-lock-test.sh @@ -0,0 +1,336 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +sleep_lock="$ROOT/bin/omarchy-system-sleep-lock" +tmpdir=$(mktemp -d) +trap 'rm -rf "$tmpdir"' EXIT + +# Each scenario gets its own mock PATH and call log, then runs the sleep lock +# with a short budget so a stalled shell cannot slow the suite down. +setup_scenario() { + scenario_dir="$tmpdir/$1" + mock_bin="$scenario_dir/bin" + call_log="$scenario_dir/calls" + state_dir="$scenario_dir/state" + notify_log="$scenario_dir/notifications" + journal_log="$scenario_dir/journal" + mkdir -p "$mock_bin" "$state_dir" + : >"$notify_log" + : >"$journal_log" + + # The budget is derived from logind, so pin the window rather than letting the + # host's own configuration decide what these scenarios are testing. + mock_logind_window 5000000 + + # Capture the desktop warning instead of firing a real one at whoever is + # running the suite. + cat >"$mock_bin/omarchy-notification-send" <>"$notify_log" +SH + chmod +x "$mock_bin/omarchy-notification-send" +} + +mock_logind_window() { + cat >"$mock_bin/busctl" <"$mock_bin/omarchy-hyprland-monitor-clamshell" <>"\$CALL_LOG" +sleep ${1:-0} +SH + chmod +x "$mock_bin/omarchy-hyprland-monitor-clamshell" +} + +# Called with no budget to exercise the value derived from logind's window. +run_sleep_lock() { + local args=() + [[ -n ${1:-} ]] && args=("$1") + + start_us=${EPOCHREALTIME//[!0-9]/} + set +e + CALL_LOG="$call_log" STATE_DIR="$state_dir" PATH="$mock_bin:$PATH" \ + "$sleep_lock" "${args[@]}" 2>"$journal_log" + exit_status=$? + set -e + elapsed_us=$((10#${EPOCHREALTIME//[!0-9]/} - 10#$start_us)) + + mapfile -t calls <"$call_log" +} + +# A responsive shell locks immediately, even when the clamshell sync stalls. +setup_scenario responsive +cat >"$mock_bin/omarchy-shell" <<'SH' +#!/bin/bash + +printf 'shell %s\n' "$*" >>"$CALL_LOG" +if [[ $* == "lock lock" ]]; then + printf 'ok\n' +elif [[ $* == "lock status" ]]; then + printf '{"secure":true}\n' +fi +SH +chmod +x "$mock_bin/omarchy-shell" +mock_clamshell 2 + +run_sleep_lock 4000 + +(( exit_status == 0 )) || + fail "sleep lock succeeds once the session reports secure" "exit: $exit_status" +pass "sleep lock succeeds once the session reports secure" + +[[ ${calls[0]} == "shell lock lock" ]] || + fail "sleep lock requests the session lock first" "first call: ${calls[0]}" +pass "sleep lock requests the session lock first" + +[[ ${calls[1]} == "clamshell" && ${calls[2]} == "shell lock status" ]] || + fail "sleep lock checks security after clamshell reconciliation" +pass "sleep lock checks security after clamshell reconciliation" + +(( elapsed_us < 1500000 )) || + fail "sleep lock bounds a stalled clamshell sync" "elapsed: ${elapsed_us}us" +pass "sleep lock bounds a stalled clamshell sync" + +# A shell that never secures the session must give up inside the budget rather +# than hold logind's delay inhibitor open. +setup_scenario never_secure +cat >"$mock_bin/omarchy-shell" <<'SH' +#!/bin/bash + +printf 'shell %s\n' "$*" >>"$CALL_LOG" +if [[ $* == "lock lock" ]]; then + printf 'ok\n' +elif [[ $* == "lock status" ]]; then + printf '{"secure":false}\n' +fi +SH +chmod +x "$mock_bin/omarchy-shell" +mock_clamshell + +run_sleep_lock 1500 + +(( exit_status != 0 )) || + fail "sleep lock reports failure when the session never secures" +pass "sleep lock reports failure when the session never secures" + +# The contract is the budget plus at most one poll interval, since the pause +# between polls is not itself clipped. Derived budgets hold back a full second +# for logind, so that overshoot is always well inside the reserve. +(( elapsed_us <= 1600000 )) || + fail "sleep lock gives up within its budget" "elapsed: ${elapsed_us}us" +pass "sleep lock gives up within its budget" + +polls=0 +for call in "${calls[@]}"; do + [[ $call == "shell lock status" ]] && (( ++polls )) +done +(( polls > 1 )) || + fail "sleep lock keeps polling until the deadline" "polls: $polls" +pass "sleep lock keeps polling until the deadline" + +# A lock request that times out may never have landed, so the wait retries it +# instead of suspending an unlocked session over one slow IPC call. +setup_scenario retry_lock +cat >"$mock_bin/omarchy-shell" <<'SH' +#!/bin/bash + +printf 'shell %s\n' "$*" >>"$CALL_LOG" + +if [[ $* == "lock lock" ]]; then + if [[ -f $STATE_DIR/requested ]]; then + touch "$STATE_DIR/locked" + printf 'ok\n' + exit 0 + fi + touch "$STATE_DIR/requested" + exit 1 +fi + +if [[ $* == "lock status" ]]; then + if [[ -f $STATE_DIR/locked ]]; then + printf '{"secure":true}\n' + else + printf '{"secure":false}\n' + fi +fi +SH +chmod +x "$mock_bin/omarchy-shell" +mock_clamshell + +run_sleep_lock 4000 + +(( exit_status == 0 )) || + fail "sleep lock retries a failed lock request" "exit: $exit_status" +pass "sleep lock retries a failed lock request" + +requests=0 +for call in "${calls[@]}"; do + [[ $call == "shell lock lock" ]] && (( ++requests )) +done +(( requests == 2 )) || + fail "sleep lock stops requesting once the lock lands" "requests: $requests" +pass "sleep lock stops requesting once the lock lands" + +# A request can land even when its IPC response times out. Pending status proves +# that Quickshell is already securing the session, so do not spend the remaining +# inhibitor budget sending the same request again. +setup_scenario pending_lock +cat >"$mock_bin/omarchy-shell" <<'SH' +#!/bin/bash + +printf 'shell %s\n' "$*" >>"$CALL_LOG" + +if [[ $* == "lock lock" ]]; then + exit 1 +fi + +if [[ $* == "lock status" ]]; then + if [[ -f $STATE_DIR/pending_seen ]]; then + printf '{"secure":true}\n' + else + touch "$STATE_DIR/pending_seen" + printf '{"secure":false,"requested":true,"pending":true,"sessionLocked":false}\n' + fi +fi +SH +chmod +x "$mock_bin/omarchy-shell" +mock_clamshell + +run_sleep_lock 4000 + +(( exit_status == 0 )) || + fail "sleep lock succeeds after observing a pending lock" "exit: $exit_status" +pass "sleep lock succeeds after observing a pending lock" + +requests=0 +for call in "${calls[@]}"; do + [[ $call == "shell lock lock" ]] && (( ++requests )) +done +(( requests == 1 )) || + fail "sleep lock does not retry an observed pending lock" "requests: $requests" +pass "sleep lock does not retry an observed pending lock" + +# The shell reports a refusal on stdout with a zero exit, so a lock it can never +# perform has to end the wait instead of burning the rest of the window on it. +setup_scenario missing_pam +cat >"$mock_bin/omarchy-shell" <<'SH' +#!/bin/bash + +printf 'shell %s\n' "$*" >>"$CALL_LOG" +if [[ $* == "lock lock" ]]; then + printf 'missing-pam\n' +fi +exit 0 +SH +chmod +x "$mock_bin/omarchy-shell" +mock_clamshell + +run_sleep_lock 4000 + +(( exit_status != 0 )) || + fail "sleep lock fails fast when the shell cannot lock at all" +(( elapsed_us < 500000 )) || + fail "sleep lock fails fast when the shell cannot lock at all" "elapsed: ${elapsed_us}us" +pass "sleep lock fails fast when the shell cannot lock at all" + +[[ ${calls[*]} != *"lock status"* ]] || + fail "sleep lock stops polling a shell that refused to lock" "calls: ${calls[*]}" +pass "sleep lock stops polling a shell that refused to lock" + +# logind suspends regardless of this exit status, so an unlocked suspend is +# otherwise invisible. The warning is the only trace the user ever sees, and the +# journal line is what makes it diagnosable after the fact. +grep -qF "did not lock before suspend" "$notify_log" || + fail "sleep lock warns that the session was left unlocked" \ + "notifications: $(< "$notify_log")" +pass "sleep lock warns that the session was left unlocked" + +grep -qF "suspending without a secure lock" "$journal_log" || + fail "sleep lock records the unlocked suspend in the journal" \ + "journal: $(< "$journal_log")" +pass "sleep lock records the unlocked suspend in the journal" + +# A never-securing shell is the scenario that runs out the whole budget, so it +# is also the one that shows which budget was derived. +never_secures() { + cat >"$mock_bin/omarchy-shell" <<'SH' +#!/bin/bash + +printf 'shell %s\n' "$*" >>"$CALL_LOG" +if [[ $* == "lock lock" ]]; then + printf 'ok\n' +elif [[ $* == "lock status" ]]; then + printf '{"secure":false,"requested":true,"pending":true,"sessionLocked":false}\n' +fi +SH + chmod +x "$mock_bin/omarchy-shell" + mock_clamshell +} + +# The drop-in only counts once logind has reloaded it, and a machine can carry +# its own override, so the budget follows whatever logind actually enforces. +setup_scenario derived_short_window +mock_logind_window 2000000 +never_secures + +run_sleep_lock + +(( elapsed_us <= 1300000 )) || + fail "sleep lock derives its budget from logind's window" "elapsed: ${elapsed_us}us" +pass "sleep lock derives its budget from logind's window" + +# Without a readable window there is no way to know what logind will tolerate, +# so fall back to the budget that was safe before the drop-in existed. +setup_scenario unreadable_window +cat >"$mock_bin/busctl" <<'SH' +#!/bin/bash +exit 1 +SH +chmod +x "$mock_bin/busctl" +never_secures + +run_sleep_lock + +(( elapsed_us > 1300000 && elapsed_us <= 4300000 )) || + fail "sleep lock falls back to a conservative budget" "elapsed: ${elapsed_us}us" +pass "sleep lock falls back to a conservative budget when logind cannot be read" + +# A hand-raised window must not strand a closed laptop awake in a bag. This +# scenario runs for the whole capped budget by design. +setup_scenario capped_window +mock_logind_window 600000000 +never_secures + +run_sleep_lock + +(( exit_status != 0 )) || + fail "sleep lock caps the budget a huge logind window would allow" +(( elapsed_us <= 12500000 )) || + fail "sleep lock caps the budget a huge logind window would allow" \ + "elapsed: ${elapsed_us}us" +pass "sleep lock caps the budget a huge logind window would allow" + +# The cap is only reachable because the shipped drop-in widens logind's window +# past it. Ship one without the other and the cap is dead weight. +inhibit_delay=$(sed -n 's/^InhibitDelayMaxSec=//p' "$ROOT/etc/systemd/logind.conf.d/20-inhibit-delay.conf") +budget_cap_ms=$(sed -n 's/^budget_cap_ms=//p' "$sleep_lock") + +[[ -n $inhibit_delay && -n $budget_cap_ms ]] || + fail "sleep lock cap and logind window are both declared" \ + "window: ${inhibit_delay:-unset} cap: ${budget_cap_ms:-unset}" +(( budget_cap_ms < inhibit_delay * 1000 )) || + fail "sleep lock cap leaves logind room to act" \ + "cap: ${budget_cap_ms}ms window: ${inhibit_delay}s" +pass "sleep lock cap stays inside the shipped logind inhibitor window" diff --git a/test/shell.d/systemd-test.sh b/test/shell.d/systemd-test.sh index cb9bd169..b6346495 100755 --- a/test/shell.d/systemd-test.sh +++ b/test/shell.d/systemd-test.sh @@ -28,12 +28,19 @@ grep -F 'ExecStart=/usr/bin/omarchy-system-sleep-monitor' "$upgrade_to_quattro" grep -F 'reset-failed omarchy-sleep-lock.service' "$upgrade_to_quattro" >/dev/null pass "Omarchy 4 upgrade repairs the legacy sleep lock unit path" -notify_path="$ROOT/default/systemd/user/omarchy-update-user-notify.path" -! grep -q 'PathExistsGlob' "$notify_path" -grep -Fx 'PathModified=/usr/share/omarchy/migrations' "$notify_path" >/dev/null -pass "migration watcher is edge-triggered so applied migrations on disk cannot re-trigger it" +[[ -e $ROOT/default/systemd/user/omarchy-update-user-notify.path ]] && + fail "the retired migration watcher is back; pacman writing the migration directory during omarchy update would notify about migrations that update is already applying" +grep -rlE '^(Path[A-Za-z]+|DirectoryNotEmpty)=.*/usr/share/omarchy/migrations' "$ROOT/default/systemd/user" >/dev/null 2>&1 && + fail "a user unit watches the migration directory again; the notifier must stay login-only" +pass "no unit watches the migration directory, so package updates cannot trigger the notifier" -notify_service="$ROOT/default/systemd/user/omarchy-update-user-notify.service" -! grep -q 'StartLimit' "$notify_service" +notify_service="$ROOT/default/systemd/user/omarchy-migrate-notify.service" +grep -Fx 'ExecStart=/usr/bin/omarchy-migrate-notify' "$notify_service" >/dev/null grep -Fx 'WantedBy=graphical-session.target' "$notify_service" >/dev/null -pass "migration notifier keeps its start-rate limit and still runs once per login" +pass "migration notifier only checks once per login" + +grep -F 'omarchy-migrate-notify.service' "$first_run_units" >/dev/null || + fail "first-run does not enable the login migration notifier" +grep -F 'omarchy-update-user-notify' "$first_run_units" >/dev/null && + fail "first-run still enables the retired notifier units" +pass "first-run enables the login-only migration notifier" diff --git a/test/shell.d/tmux-alert-test.sh b/test/shell.d/tmux-alert-test.sh new file mode 100644 index 00000000..19aecf84 --- /dev/null +++ b/test/shell.d/tmux-alert-test.sh @@ -0,0 +1,117 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +require_command jq + +run_node_test <<'JS' +const tmux = requireFromRoot('shell/plugins/services/tmux/TmuxModel.js') + +assertEqual(tmux.waitingFromOutput('{"count":2,"tooltip":"claude (Work:2)"}').count, 2, 'tmux model reads the waiting count') +assertEqual(tmux.waitingFromOutput('{"count":2,"tooltip":"claude (Work:2)"}').tooltip, 'claude (Work:2)', 'tmux model reads the tooltip') +assertEqual(tmux.waitingFromOutput('shell noise\n{"count":1,"tooltip":"editor (Work:1)"}').count, 1, 'tmux model ignores output before the json line') +assertEqual(tmux.waitingFromOutput('').count, 0, 'tmux model treats empty output as nothing waiting') +assertEqual(tmux.waitingFromOutput('not json').count, 0, 'tmux model treats unparseable output as nothing waiting') +assertEqual(tmux.waitingFromOutput('not json').tooltip, '', 'tmux model blanks the tooltip on unparseable output') +assertEqual(tmux.waitingFromOutput('{"count":-3}').count, 0, 'tmux model clamps a negative count') +assertEqual(tmux.waitingFromOutput('{"tooltip":"editor (Work:1)"}').count, 0, 'tmux model defaults a missing count') +JS + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +cat >"$test_dir/tmux" <<'STUB' +#!/bin/bash + +if [[ $1 == "list-windows" ]]; then + cat "$TMUX_STUB_WINDOWS" +elif [[ $1 == "list-clients" ]]; then + cat "$TMUX_STUB_CLIENTS" +elif [[ $1 == "set-option" ]]; then + printf '%s\n' "$*" >>"$TMUX_STUB_CALLS" +fi +STUB +chmod +x "$test_dir/tmux" + +cat >"$test_dir/omarchy-shell" <<'STUB' +#!/bin/bash + +printf '%s\n' "$*" >>"$TMUX_STUB_CALLS" +STUB +chmod +x "$test_dir/omarchy-shell" + +export TMUX_STUB_WINDOWS="$test_dir/windows" +export TMUX_STUB_CLIENTS="$test_dir/clients" +export TMUX_STUB_CALLS="$test_dir/calls" +export PATH="$test_dir:$PATH" + +cat >"$TMUX_STUB_WINDOWS" <<'WINDOWS' +@1:000:100::Work:1:editor +@2:100:110::Work:2:claude +@3:001:120::Side|Gig:1:server: still going +WINDOWS +: >"$TMUX_STUB_CLIENTS" + +output=$("$ROOT/bin/omarchy-tmux-alert" show --json) +expected='{"count":2,"tooltip":"claude (Work:2), server: still going (Side|Gig:1)"}' +[[ $output == "$expected" ]] || fail "tmux alert reports alerted windows" "expected: $expected"$'\n'"actual: $output" +pass "tmux alert reports alerted windows" + +output=$("$ROOT/bin/omarchy-tmux-alert" show) +[[ $output == "claude (Work:2), server: still going (Side|Gig:1)" ]] || fail "tmux alert describes alerted windows" "$output" +pass "tmux alert describes alerted windows" + +cat >"$TMUX_STUB_WINDOWS" <<'WINDOWS' +@1:000:200:100:Work:1:editor +WINDOWS +echo 'attached,UTF-8:@1' >"$TMUX_STUB_CLIENTS" + +output=$("$ROOT/bin/omarchy-tmux-alert" show --json) +expected='{"count":1,"tooltip":"editor (Work:1)"}' +[[ $output == "$expected" ]] || fail "tmux alert reports output in an unfocused active window" "expected: $expected"$'\n'"actual: $output" +pass "tmux alert reports output in an unfocused active window" + +echo 'attached,focused,UTF-8:@1' >"$TMUX_STUB_CLIENTS" +output=$("$ROOT/bin/omarchy-tmux-alert" show --json) +[[ $output == '{"count":0,"tooltip":""}' ]] || fail "tmux alert ignores output in a focused active window" "$output" +pass "tmux alert ignores output in a focused active window" + +cat >"$TMUX_STUB_CLIENTS" <<'CLIENTS' +attached,UTF-8:@1 +attached,focused,UTF-8:@1 +CLIENTS +output=$("$ROOT/bin/omarchy-tmux-alert" show --json) +[[ $output == '{"count":0,"tooltip":""}' ]] || fail "tmux alert ignores a window visible in another focused client" "$output" +pass "tmux alert ignores a window visible in another focused client" + +echo 'attached,UTF-8:@1' >"$TMUX_STUB_CLIENTS" +cat >"$TMUX_STUB_WINDOWS" <<'WINDOWS' +@1:000:200:200:Work:1:editor +WINDOWS + +output=$("$ROOT/bin/omarchy-tmux-alert" show --json) +[[ $output == '{"count":0,"tooltip":""}' ]] || fail "tmux alert reports no alerted windows" "$output" +pass "tmux alert reports no alerted windows" + +[[ -z $("$ROOT/bin/omarchy-tmux-alert" show) ]] || fail "tmux alert stays quiet without alerts" +pass "tmux alert stays quiet without alerts" + +: >"$TMUX_STUB_WINDOWS" +: >"$TMUX_STUB_CLIENTS" +output=$("$ROOT/bin/omarchy-tmux-alert" show --json) +[[ $output == '{"count":0,"tooltip":""}' ]] || fail "tmux alert handles a missing tmux server" "$output" +pass "tmux alert handles a missing tmux server" + +: >"$TMUX_STUB_CALLS" +"$ROOT/bin/omarchy-tmux-alert" track @3 345 +expected=$'set-option -wq -t @3 @omarchy_unfocused_activity 345\n-q omarchy.indicators refresh' +output=$(cat "$TMUX_STUB_CALLS") +[[ $output == "$expected" ]] || fail "tmux alert records the activity watermark and refreshes indicators" "expected: $expected"$'\n'"actual: $output" +pass "tmux alert records the activity watermark and refreshes indicators" + +: >"$TMUX_STUB_CALLS" +"$ROOT/bin/omarchy-tmux-alert" track invalid nope +[[ ! -s $TMUX_STUB_CALLS ]] || fail "tmux alert ignores invalid tracking arguments" "$(cat "$TMUX_STUB_CALLS")" +pass "tmux alert ignores invalid tracking arguments" diff --git a/test/shell.d/tmux-hidden-activity-migration-test.sh b/test/shell.d/tmux-hidden-activity-migration-test.sh new file mode 100644 index 00000000..174532c1 --- /dev/null +++ b/test/shell.d/tmux-hidden-activity-migration-test.sh @@ -0,0 +1,54 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "$0")/base-test.sh" + +migration="$ROOT/migrations/1784955584.sh" +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +mkdir -p "$test_dir/home/.config/tmux" "$test_dir/bin" + +cat >"$test_dir/home/.config/tmux/tmux.conf" <<'EOF' +# Alerts +set-hook -g alert-bell 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' +set-hook -g after-select-window 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' +set-hook -g client-session-changed 'run-shell -b "omarchy-shell -q omarchy.indicators refresh"' +set-hook -g client-focus-out[42] 'display-message "custom focus hook"' +EOF + +cat >"$test_dir/bin/omarchy-restart-tmux" <<'EOF' +#!/bin/bash + +echo restart >>"$TMUX_MIGRATION_RESTART_LOG" +EOF +chmod +x "$test_dir/bin/omarchy-restart-tmux" + +export TMUX_MIGRATION_RESTART_LOG="$test_dir/restarts" +tmux_config="$test_dir/home/.config/tmux/tmux.conf" + +HOME="$test_dir/home" PATH="$test_dir/bin:$PATH" bash -euo pipefail "$migration" >/dev/null + +grep -Fq 'after-select-window '"'"'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"'"'" "$tmux_config" || + fail "tmux activity migration tracks newly selected windows" +grep -Fq 'client-session-changed '"'"'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"'"'" "$tmux_config" || + fail "tmux activity migration tracks session changes" +grep -Fq 'client-focus-out[100] '"'"'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"'"'" "$tmux_config" || + fail "tmux activity migration tracks terminal focus loss" +grep -Fq 'client-focus-in[100] '"'"'run-shell -b "omarchy-tmux-alert track #{window_id} #{window_activity}"'"'" "$tmux_config" || + fail "tmux activity migration tracks terminal focus gain" +grep -Fq 'client-focus-out[42] '"'"'display-message "custom focus hook"'"'" "$tmux_config" || + fail "tmux activity migration preserves custom indexed focus hooks" +restart_count=$(wc -l <"$TMUX_MIGRATION_RESTART_LOG") +((restart_count == 1)) || fail "tmux activity migration reloads tmux once" +pass "tmux activity migration installs focus-aware hooks" + +before=$(sha256sum "$tmux_config") +HOME="$test_dir/home" PATH="$test_dir/bin:$PATH" bash -euo pipefail "$migration" >/dev/null +after=$(sha256sum "$tmux_config") + +[[ $before == "$after" ]] || fail "tmux activity migration is idempotent" +restart_count=$(wc -l <"$TMUX_MIGRATION_RESTART_LOG") +((restart_count == 1)) || fail "idempotent tmux activity migration does not reload tmux" +pass "tmux activity migration is idempotent" diff --git a/test/shell.d/update-available-test.sh b/test/shell.d/update-available-test.sh index 40cfbd1c..60158863 100644 --- a/test/shell.d/update-available-test.sh +++ b/test/shell.d/update-available-test.sh @@ -8,6 +8,7 @@ test_tmp=$(mktemp -d) trap 'rm -rf "$test_tmp"' EXIT stub_bin="$test_tmp/bin" +git_log="$test_tmp/git.log" mkdir -p "$stub_bin" cat >"$stub_bin/checkupdates" <<'SH' @@ -52,8 +53,48 @@ exit 0 SH chmod +x "$stub_bin/pacman" +cat >"$stub_bin/git" <<'SH' +#!/bin/bash + +printf '%s\n' "$*" >>"$TEST_GIT_LOG" + +[[ $1 == "-C" ]] || exit 1 +shift 2 + +case "$1" in + fetch) + [[ ${TEST_GIT_FETCH:-ok} == "ok" ]] + ;; + rev-parse) + case "$2" in + --is-inside-work-tree) + [[ ${TEST_GIT_CHECKOUT:-yes} == "yes" ]] || exit 1 + echo true + ;; + --abbrev-ref) + [[ ${TEST_GIT_UPSTREAM:-origin/quattro} != "none" ]] || exit 1 + echo "${TEST_GIT_UPSTREAM:-origin/quattro}" + ;; + *) + exit 1 + ;; + esac + ;; + rev-list) + echo "${TEST_GIT_BEHIND:-0}" + ;; + *) + exit 1 + ;; +esac +SH +chmod +x "$stub_bin/git" + run_checker() { - PATH="$stub_bin:$PATH" "$ROOT/bin/omarchy-update-available" + OMARCHY_PATH="${TEST_OMARCHY_PATH:-/usr/share/omarchy}" \ + TEST_GIT_LOG="$git_log" \ + PATH="$stub_bin:$PATH" \ + "$ROOT/bin/omarchy-update-available" } capture_checker() { @@ -124,3 +165,49 @@ fi [[ $status -eq 1 ]] || fail "update checker exits non-zero when no updates are available" grep -q '^Omarchy is up to date$' "$stdout" || fail "update checker prints up-to-date message" pass "update checker reports up-to-date Omarchy packages" + +: >"$git_log" +if capture_checker "$stdout" "$stderr" \ + TEST_CHECKUPDATES=none \ + TEST_INSTALLED_PACKAGE=none \ + TEST_OMARCHY_PATH="$test_tmp/checkout" \ + TEST_GIT_BEHIND=2; then + status=0 +else + status=$? +fi +[[ $status -eq 0 ]] || fail "update checker exits successfully when dev commits are available" +grep -Fx 'omarchy-dev-checkout 2 new commits on origin/quattro' "$stdout" >/dev/null || + fail "update checker reports available dev commits" "$(cat "$stdout")" +grep -Fx -- "-C $test_tmp/checkout fetch --quiet" "$git_log" >/dev/null || + fail "update checker fetches the dev checkout upstream" "$(cat "$git_log")" +pass "update checker detects new commits in the dev checkout" + +if capture_checker "$stdout" "$stderr" \ + TEST_CHECKUPDATES=none \ + TEST_INSTALLED_PACKAGE=none \ + TEST_OMARCHY_PATH="$test_tmp/checkout" \ + TEST_GIT_BEHIND=0; then + status=0 +else + status=$? +fi +[[ $status -eq 1 ]] || fail "update checker exits non-zero when the dev checkout is current" +grep -q '^Omarchy is up to date$' "$stdout" || fail "update checker reports a current dev checkout" +pass "update checker ignores a current dev checkout" + +if capture_checker "$stdout" "$stderr" \ + TEST_CHECKUPDATES=none \ + TEST_INSTALLED_PACKAGE=none \ + TEST_OMARCHY_PATH="$test_tmp/checkout" \ + TEST_GIT_BEHIND=1 \ + TEST_GIT_FETCH=fail; then + status=0 +else + status=$? +fi +[[ $status -eq 0 ]] || fail "update checker uses cached upstream state when fetch fails" +grep -Fx 'omarchy-dev-checkout 1 new commit on origin/quattro' "$stdout" >/dev/null || + fail "update checker reports cached dev commits after a fetch failure" "$(cat "$stdout")" +[[ ! -s $stderr ]] || fail "update checker keeps dev fetch failures quiet" "$(cat "$stderr")" +pass "update checker uses cached dev state when fetching is unavailable" diff --git a/test/shell.d/update-dev-test.sh b/test/shell.d/update-dev-test.sh new file mode 100644 index 00000000..68da418a --- /dev/null +++ b/test/shell.d/update-dev-test.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +stub_bin="$test_tmp/bin" +git_log="$test_tmp/git.log" +checkout="$test_tmp/checkout" +mkdir -p "$stub_bin" "$checkout" + +cat >"$stub_bin/git" <<'SH' +#!/bin/bash + +printf '%s\n' "$*" >>"$TEST_GIT_LOG" + +[[ $1 == "-C" ]] || exit 1 +shift 2 + +case "$1" in + rev-parse) + case "$2" in + --is-inside-work-tree) + [[ ${TEST_GIT_CHECKOUT:-yes} == "yes" ]] || exit 1 + echo true + ;; + --abbrev-ref) + [[ ${TEST_GIT_UPSTREAM:-origin/quattro} != "none" ]] || exit 1 + echo "${TEST_GIT_UPSTREAM:-origin/quattro}" + ;; + *) + exit 1 + ;; + esac + ;; + pull) + [[ $2 == "--ff-only" ]] + ;; + *) + exit 1 + ;; +esac +SH +chmod +x "$stub_bin/git" + +run_dev_update() { + OMARCHY_PATH="$1" \ + TEST_GIT_LOG="$git_log" \ + PATH="$stub_bin:$PATH" \ + "$ROOT/bin/omarchy-update-dev" +} + +: >"$git_log" +run_dev_update /usr/share/omarchy +[[ ! -s $git_log ]] || fail "package-backed updates do not invoke git" "$(cat "$git_log")" +pass "package-backed updates skip the dev checkout step" + +: >"$git_log" +run_dev_update "$checkout" +grep -Fx -- "-C $checkout pull --ff-only" "$git_log" >/dev/null || + fail "dev checkout update pulls its upstream with fast-forward only" "$(cat "$git_log")" +pass "dev checkout update pulls its configured upstream" + +: >"$git_log" +TEST_GIT_UPSTREAM=none run_dev_update "$checkout" +if grep -q ' pull ' "$git_log"; then + fail "dev checkout without an upstream is not pulled" "$(cat "$git_log")" +fi +pass "dev checkout without an upstream is skipped safely" + +: >"$git_log" +if TEST_GIT_CHECKOUT=no run_dev_update "$checkout" >"$test_tmp/invalid.out" 2>"$test_tmp/invalid.err"; then + fail "invalid dev checkout fails the update" +fi +grep -F "OMARCHY_PATH is not a git checkout: $checkout" "$test_tmp/invalid.err" >/dev/null || + fail "invalid dev checkout reports the configured path" "$(cat "$test_tmp/invalid.err")" +pass "invalid dev checkout fails with a useful error" + +grep -qE '^ *omarchy-update-dev$' "$ROOT/bin/omarchy-update" || + fail "top-level update includes the dev checkout step" +pass "top-level update includes the dev checkout step" diff --git a/test/shell.d/update-lock-test.sh b/test/shell.d/update-lock-test.sh index aef0b6fe..37c03258 100644 --- a/test/shell.d/update-lock-test.sh +++ b/test/shell.d/update-lock-test.sh @@ -34,6 +34,7 @@ SH for command in \ omarchy-toggle-idle \ systemd-inhibit \ + omarchy-update-dev \ omarchy-update-keyring \ omarchy-update-system-pkgs \ omarchy-migrate \ @@ -99,3 +100,36 @@ wait "$perform_pid" grep -q "already running" "$test_tmp/perform-second.out" || fail "second omarchy-update-perform reports held update lock" [[ ! -f $test_tmp/perform-second-started ]] || fail "second omarchy-update-perform did not snapshot while lock was held" pass "omarchy-update-perform compatibility wrapper respects update lock" + +# Update-owned Stay Awake state must be cleared before the restart helper can +# reboot the machine, rather than relying on an EXIT trap during shutdown. +write_stub omarchy-snapshot 'exit 0' +write_stub omarchy-update-keyring 'exit 0' +write_stub omarchy-toggle-idle ' +state_file="$HOME/.local/state/omarchy/indicators/stay-awake" +case "$1" in + stay-awake) + mkdir -p "$(dirname "$state_file")" + touch "$state_file" + ;; + allow-idle) + rm -f "$state_file" + ;; +esac' +write_stub omarchy-update-restart ' +state_file="$HOME/.local/state/omarchy/indicators/stay-awake" +if [[ ${EXPECT_STAY_AWAKE:-0} == "1" ]]; then + [[ -f $state_file ]] +else + [[ ! -f $state_file ]] +fi' + +rm -f "$test_home/.local/state/omarchy/indicators/stay-awake" +OMARCHY_UPDATE_LOGGED=1 run_with_lock_env "$ROOT/bin/omarchy-update" -y +[[ ! -f $test_home/.local/state/omarchy/indicators/stay-awake ]] || fail "update clears its Stay Awake state before restart handling" + +mkdir -p "$test_home/.local/state/omarchy/indicators" +touch "$test_home/.local/state/omarchy/indicators/stay-awake" +OMARCHY_UPDATE_LOGGED=1 EXPECT_STAY_AWAKE=1 run_with_lock_env "$ROOT/bin/omarchy-update" -y +[[ -f $test_home/.local/state/omarchy/indicators/stay-awake ]] || fail "update preserves pre-existing Stay Awake state" +pass "omarchy-update restores only its own Stay Awake state before restart handling" diff --git a/test/shell.d/voxtype-invitation-test.sh b/test/shell.d/voxtype-invitation-test.sh index 827e4638..6038950f 100644 --- a/test/shell.d/voxtype-invitation-test.sh +++ b/test/shell.d/voxtype-invitation-test.sh @@ -32,10 +32,13 @@ cat >"$test_bin/systemd-run" <<'EOF' #!/bin/bash echo "systemd-run:$*" >>"$TEST_LOG" while (($# > 0)); do - [[ $1 == "bash" ]] && exec "$@" - shift + case $1 in + -p) shift 2 ;; + -*) shift ;; + *) break ;; + esac done -exit 1 +exec "$@" EOF chmod +x "$test_bin/systemd-run" @@ -48,15 +51,18 @@ run_invitation_hook [[ -f $test_home/.local/state/omarchy/done/voxtype-install-invitation ]] || fail "Voxtype invitation records completion" [[ -f $hook_path ]] || fail "Voxtype invitation keeps its hook installed" -[[ $(grep -c '^systemd-run:' "$log_file") -eq 1 ]] || fail "Voxtype invitation uses a durable user service" +[[ $(grep -c '^systemd-run:' "$log_file") -eq 2 ]] || fail "Voxtype invitation uses durable user services" grep -q -- '--user --collect --quiet --service-type=exec --unit=omarchy-voxtype-install-invitation' "$log_file" || fail "Voxtype invitation configures its user service" +# KillMode=process keeps the launcher's setsid child alive once the short-lived +# main process exits, otherwise the install terminal never appears. +grep -q -- '--user --collect --quiet -p KillMode=process --unit=omarchy-voxtype-install ' "$log_file" || fail "Voxtype invitation outlives its launcher unit" [[ $(grep -c '^notification$' "$log_file") -eq 1 ]] || fail "Voxtype invitation sends one notification" [[ $(grep -c '^launch$' "$log_file") -eq 1 ]] || fail "Voxtype invitation handles the notification action" HOME="$test_home" PATH="$test_bin:$ROOT/bin:$PATH" TEST_LOG="$log_file" bash "$hook_path" [[ -f $hook_path ]] || fail "completed Voxtype invitation keeps its hook installed" -[[ $(grep -c '^systemd-run:' "$log_file") -eq 1 ]] || fail "completed Voxtype invitation does not schedule again" +[[ $(grep -c '^systemd-run:' "$log_file") -eq 2 ]] || fail "completed Voxtype invitation does not schedule again" [[ $(grep -c '^notification$' "$log_file") -eq 1 ]] || fail "completed Voxtype invitation hook does not notify again" [[ $(grep -c '^launch$' "$log_file") -eq 1 ]] || fail "completed Voxtype invitation hook does not launch again" diff --git a/test/shell.d/zram-migration-test.sh b/test/shell.d/zram-migration-test.sh new file mode 100644 index 00000000..cf54c30f --- /dev/null +++ b/test/shell.d/zram-migration-test.sh @@ -0,0 +1,104 @@ +#!/bin/bash + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +migration=$(grep -rl 'Move zram tuning to a vendor drop-in' "$ROOT/migrations" | head -n 1 || true) +[[ -n $migration ]] || fail "zram drop-in migration exists" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# The migration shells out to pacman (ownership check) and sudo (removal). +# Stub both so the test never touches the real system, and let each case pick +# what `pacman -Qo` reports through PACMAN_OWNS. +stub_bin="$TMPDIR/bin" +mkdir -p "$stub_bin" + +cat >"$stub_bin/pacman" <<'STUB' +#!/bin/bash +[[ ${PACMAN_OWNS:-0} == 1 ]] +STUB + +cat >"$stub_bin/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB + +chmod +x "$stub_bin/pacman" "$stub_bin/sudo" + +# The migration removes the /etc copy only once the drop-in that replaces it is +# installed. Point that at a fixture so the result does not depend on whether +# the machine running the tests happens to carry the real one. +dropin="$TMPDIR/90-omarchy.conf" +: >"$dropin" + +# omarchy-migrate runs each migration with `bash -euo pipefail` and stops the +# whole chain on a non-zero exit, so match that invocation exactly. +run_migration() { + local conf="$1" + PATH="$stub_bin:$PATH" OMARCHY_ZRAM_CONF="$conf" OMARCHY_ZRAM_DROPIN="$dropin" \ + bash -euo pipefail "$migration" >/dev/null || + fail "migration exits clean for $(basename "$conf")" +} + +# archinstall's own output: a [zram0] section with nothing but the algorithm. +conf="$TMPDIR/archinstall.conf" +printf '[zram0]\ncompression-algorithm = zstd\n' >"$conf" +run_migration "$conf" +[[ -f $conf ]] && fail "migration removes archinstall's generated config" +pass "migration removes archinstall's generated config" + +# Same shape, different algorithm, plus comments and blank lines. +conf="$TMPDIR/commented.conf" +printf '# written by archinstall\n\n[zram0]\ncompression-algorithm = lz4\n\n' >"$conf" +run_migration "$conf" +[[ -f $conf ]] && fail "migration ignores comments and a non-zstd algorithm" +pass "migration ignores comments and a non-zstd algorithm" + +# A config that sets nothing decides nothing, and must not take the migration +# chain down with it. +conf="$TMPDIR/comments-only.conf" +printf '# nothing to see here\n\n' >"$conf" +run_migration "$conf" +[[ -f $conf ]] && fail "migration removes a config that sets nothing" +pass "migration removes a config that sets nothing" + +conf="$TMPDIR/empty.conf" +: >"$conf" +run_migration "$conf" +[[ -f $conf ]] && fail "migration removes an empty config" +pass "migration removes an empty config" + +# A local override must survive. +conf="$TMPDIR/local.conf" +printf '[zram0]\ncompression-algorithm = zstd\nzram-size = ram / 4\n' >"$conf" +run_migration "$conf" +[[ -f $conf ]] || fail "migration keeps a locally edited config" +pass "migration keeps a locally edited config" + +# Package-owned copies go away with their package; the migration must not touch +# them. +conf="$TMPDIR/owned.conf" +printf '[zram0]\ncompression-algorithm = zstd\n' >"$conf" +PATH="$stub_bin:$PATH" PACMAN_OWNS=1 OMARCHY_ZRAM_CONF="$conf" OMARCHY_ZRAM_DROPIN="$dropin" \ + bash -euo pipefail "$migration" >/dev/null || + fail "migration exits clean for a package-owned config" +[[ -f $conf ]] || fail "migration keeps a package-owned config" +pass "migration keeps a package-owned config" + +# Nothing to do, and running twice must stay clean. +conf="$TMPDIR/absent.conf" +run_migration "$conf" +run_migration "$conf" +pass "migration no-ops when the config is already gone" + +# Without the drop-in installed, the /etc copy is the only thing configuring +# zram at all. Removing it would leave the machine with no zram device, so the +# migration has to leave it alone and stay clean doing it. +conf="$TMPDIR/no-dropin.conf" +printf '[zram0]\ncompression-algorithm = zstd\n' >"$conf" +PATH="$stub_bin:$PATH" OMARCHY_ZRAM_CONF="$conf" OMARCHY_ZRAM_DROPIN="$TMPDIR/absent-dropin.conf" \ + bash -euo pipefail "$migration" >/dev/null || + fail "migration exits clean when the drop-in is missing" +[[ -f $conf ]] || fail "migration keeps the config when the drop-in is missing" +pass "migration keeps the config until the drop-in is installed" diff --git a/test/shell.d/zram-resize-test.sh b/test/shell.d/zram-resize-test.sh new file mode 100644 index 00000000..589c55aa --- /dev/null +++ b/test/shell.d/zram-resize-test.sh @@ -0,0 +1,109 @@ +#!/bin/bash + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +require_command /usr/lib/systemd/system-generators/zram-generator + +migration=$(grep -rl 'Resize zram to match the shipped config' "$ROOT/migrations" | head -n 1 || true) +[[ -n $migration ]] || fail "zram resize migration exists" + +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# The migration shells out to sudo, systemctl and omarchy-state. Stub all three +# so the test never touches the real system, and record what each run did. +stub_bin="$TMPDIR/bin" +mkdir -p "$stub_bin" + +cat >"$stub_bin/sudo" <<'STUB' +#!/bin/bash +exec "$@" +STUB + +cat >"$stub_bin/systemctl" <<'STUB' +#!/bin/bash +echo "systemctl $*" >>"$ACTIONS" +[[ ${SYSTEMCTL_FAIL:-0} == 1 ]] && exit 1 +exit 0 +STUB + +cat >"$stub_bin/omarchy-state" <<'STUB' +#!/bin/bash +echo "omarchy-state $*" >>"$ACTIONS" +STUB + +chmod +x "$stub_bin/sudo" "$stub_bin/systemctl" "$stub_bin/omarchy-state" + +# ZRAM_GENERATOR_ROOT redirects both the config search and /proc/meminfo, so the +# migration's own generator call resolves against this fixture instead of the +# host. 16G of RAM against the shipped config is a 8192MB device. +gen_root="$TMPDIR/genroot" +mkdir -p "$gen_root/usr/lib/systemd/zram-generator.conf.d" "$gen_root/proc" +cp "$ROOT/default/systemd/zram-generator.conf.d/90-omarchy.conf" \ + "$gen_root/usr/lib/systemd/zram-generator.conf.d/" +printf 'MemTotal: %d kB\n' $((16 * 1024 * 1024)) >"$gen_root/proc/meminfo" + +desired_bytes=$((8192 * 1024 * 1024)) + +# omarchy-migrate runs each migration with `bash -euo pipefail` and stops the +# whole chain on a non-zero exit, so match that invocation exactly. +run_migration() { + local disksize="$1" used="$2" fail_reload="${3:-0}" + + printf '%s' "$disksize" >"$TMPDIR/disksize" + printf 'Filename\tType\tSize\tUsed\tPriority\n' >"$TMPDIR/swaps" + [[ -n $used ]] && + printf '/dev/zram0 partition 8388604 %s 100\n' "$used" >>"$TMPDIR/swaps" + + : >"$TMPDIR/actions" + + PATH="$stub_bin:$PATH" \ + ACTIONS="$TMPDIR/actions" \ + SYSTEMCTL_FAIL="$fail_reload" \ + ZRAM_GENERATOR_ROOT="$gen_root" \ + OMARCHY_ZRAM_DISKSIZE="$TMPDIR/disksize" \ + OMARCHY_SWAPS="$TMPDIR/swaps" \ + bash -euo pipefail "$migration" >/dev/null || + fail "migration exits clean (disksize=$disksize used=$used reload_fail=$fail_reload)" +} + +did() { grep -qF "$1" "$TMPDIR/actions"; } + +# Already the size the config asks for: the device is correct however it got +# there, so the migration must not restart it or ask for a reboot. +run_migration "$desired_bytes" 4193612 +[[ -s $TMPDIR/actions ]] && fail "correctly sized device is left alone" \ + "expected no privileged calls, got: $(cat "$TMPDIR/actions")" +pass "correctly sized device is left alone" +pass "correctly sized device asks for no reboot" + +# Right size but still in use, and the reload never even happens. +run_migration "$desired_bytes" 0 +did "systemctl restart" && fail "correctly sized empty device is left alone" +pass "correctly sized empty device is left alone" + +# Wrong size and empty: safe to resize now. +run_migration $((4096 * 1024 * 1024)) 0 +did "systemctl restart dev-zram0.swap" || fail "empty device is restarted" +did "omarchy-state set reboot-required" && fail "empty device asks for no reboot" +pass "empty device is restarted" +pass "empty device asks for no reboot" + +# Wrong size with pages stored: resizing would fault them all back in, so defer. +run_migration $((4096 * 1024 * 1024)) 4193612 +did "systemctl restart" && fail "device in use is not restarted" +did "omarchy-state set reboot-required" || fail "device in use asks for a reboot" +pass "device in use is not restarted" +pass "device in use asks for a reboot" + +# No zram device at all reads as empty, and the restart brings it up. +run_migration "" "" +did "systemctl restart dev-zram0.swap" || fail "absent device is created" +pass "absent device is created" + +# A failed daemon-reload must fall back to asking for a reboot. +run_migration $((4096 * 1024 * 1024)) 0 1 +did "systemctl restart" && fail "failed reload does not restart" +did "omarchy-state set reboot-required" || fail "failed reload asks for a reboot" +pass "failed reload does not restart" +pass "failed reload asks for a reboot" diff --git a/themes/catppuccin/waybar.css b/themes/catppuccin/waybar.css deleted file mode 100644 index bf35a404..00000000 --- a/themes/catppuccin/waybar.css +++ /dev/null @@ -1,2 +0,0 @@ -@define-color foreground #cdd6f4; -@define-color background #181824; diff --git a/themes/last-horizon/preview-unlock.png b/themes/last-horizon/preview-unlock.png index 8c9b95c6..0acec8a1 100644 Binary files a/themes/last-horizon/preview-unlock.png and b/themes/last-horizon/preview-unlock.png differ diff --git a/themes/last-horizon/waybar.css b/themes/last-horizon/waybar.css deleted file mode 100644 index ab9682ae..00000000 --- a/themes/last-horizon/waybar.css +++ /dev/null @@ -1,2 +0,0 @@ -@define-color background #0c0b0c; -@define-color foreground #FAFCFB; diff --git a/themes/lumon/waybar.css b/themes/lumon/waybar.css deleted file mode 100644 index 11f49054..00000000 --- a/themes/lumon/waybar.css +++ /dev/null @@ -1,2 +0,0 @@ -@define-color foreground #d6e2ee; -@define-color background #213442; diff --git a/themes/lupine/backgrounds/01-cherry-blossom-bokeh.jpg b/themes/lupine/backgrounds/01-cherry-blossom-bokeh.jpg new file mode 100644 index 00000000..3ba661f1 Binary files /dev/null and b/themes/lupine/backgrounds/01-cherry-blossom-bokeh.jpg differ diff --git a/themes/lupine/backgrounds/02-cherry-blossom-white.jpg b/themes/lupine/backgrounds/02-cherry-blossom-white.jpg new file mode 100644 index 00000000..8127b807 Binary files /dev/null and b/themes/lupine/backgrounds/02-cherry-blossom-white.jpg differ diff --git a/themes/lupine/backgrounds/03-pastel-clouds.jpg b/themes/lupine/backgrounds/03-pastel-clouds.jpg new file mode 100644 index 00000000..b3b7b387 Binary files /dev/null and b/themes/lupine/backgrounds/03-pastel-clouds.jpg differ diff --git a/themes/lupine/backgrounds/04-elegant-blue-wave.jpg b/themes/lupine/backgrounds/04-elegant-blue-wave.jpg new file mode 100644 index 00000000..b55adbbd Binary files /dev/null and b/themes/lupine/backgrounds/04-elegant-blue-wave.jpg differ diff --git a/themes/lupine/backgrounds/05-abstract-wave.jpg b/themes/lupine/backgrounds/05-abstract-wave.jpg new file mode 100644 index 00000000..f45ced86 Binary files /dev/null and b/themes/lupine/backgrounds/05-abstract-wave.jpg differ diff --git a/themes/lupine/backgrounds/06-omarchy.png b/themes/lupine/backgrounds/06-omarchy.png new file mode 100644 index 00000000..4a5e982d Binary files /dev/null and b/themes/lupine/backgrounds/06-omarchy.png differ diff --git a/themes/lupine/colors.toml b/themes/lupine/colors.toml new file mode 100644 index 00000000..8d12987a --- /dev/null +++ b/themes/lupine/colors.toml @@ -0,0 +1,31 @@ +mode = "light" + +accent = "#3264eb" +selection = "#d0d0d0" +muted = "#9e9e9e" + +background = "#fafafa" +dark_background = "#ececec" +darker_background = "#dedede" +lighter_background = "#f5f5f5" + +foreground = "#212121" +dark_foreground = "#757575" +light_foreground = "#424242" +bright_foreground = "#000000" + +red = "#c900c4" +yellow = "#026fde" +orange = "#026fde" +green = "#4a2fd0" +cyan = "#0c67de" +blue = "#3264eb" +magenta = "#8a4ad7" +brown = "#013a6f" + +bright_red = "#f930fb" +bright_yellow = "#358fff" +bright_green = "#9f85e0" +bright_cyan = "#3986ff" +bright_blue = "#5482ff" +bright_magenta = "#b363ff" diff --git a/themes/lupine/icons.theme b/themes/lupine/icons.theme new file mode 100644 index 00000000..24e9162c --- /dev/null +++ b/themes/lupine/icons.theme @@ -0,0 +1 @@ +Yaru-purple \ No newline at end of file diff --git a/themes/lupine/preview-unlock.png b/themes/lupine/preview-unlock.png new file mode 100644 index 00000000..558ffae6 Binary files /dev/null and b/themes/lupine/preview-unlock.png differ diff --git a/themes/lupine/preview.png b/themes/lupine/preview.png new file mode 100644 index 00000000..9a4c52b8 Binary files /dev/null and b/themes/lupine/preview.png differ diff --git a/themes/lupine/unlock.png b/themes/lupine/unlock.png new file mode 100644 index 00000000..bf2c9612 Binary files /dev/null and b/themes/lupine/unlock.png differ diff --git a/themes/retro-82/waybar.css b/themes/retro-82/waybar.css deleted file mode 100644 index f5944b10..00000000 --- a/themes/retro-82/waybar.css +++ /dev/null @@ -1,3 +0,0 @@ -@define-color bg #00172e; -@define-color foreground #f6dcac; -@define-color background alpha(@bg, 0.8); diff --git a/themes/solitude/preview-unlock.png b/themes/solitude/preview-unlock.png new file mode 100644 index 00000000..3990aa49 Binary files /dev/null and b/themes/solitude/preview-unlock.png differ diff --git a/themes/solitude/unlock.png b/themes/solitude/unlock.png new file mode 100644 index 00000000..13476d70 Binary files /dev/null and b/themes/solitude/unlock.png differ diff --git a/themes/solitude/waybar.css b/themes/solitude/waybar.css deleted file mode 100644 index 97c0f544..00000000 --- a/themes/solitude/waybar.css +++ /dev/null @@ -1,2 +0,0 @@ -@define-color background #101315; -@define-color foreground #cacccc;