Replace mako with quickshell-backed notification daemon

Adds first-party omarchy.notifications service plugin that hosts a
freedesktop notification server and renders popups + a history popup
inside the shell. Uninstalls mako and retargets every helper, keybind,
indicator, and migration entry to the new daemon.

Plugin (default/quickshell/omarchy-shell/plugins/notifications/):
- Service.qml: NotificationServer, popupModel + pendingModel + pastModel
  (two-tier history, see below), DND via PersistentProperties +
  cache-file backstop, image cache for /tmp screenshots, IpcHandler with
  toggleDnd/setDnd/isDnd/showHistory/clear/clearPending/markAllSeen/
  dismissAll/dismissOne/invokeLast/dismiss, per-theme override file
  ~/.config/omarchy/current/theme/notifications.json honoring
  borderColor/backgroundColor/textColor/countdownColor.
- components/NotificationCard.qml: theme-driven card (Color.foreground/
  background/border tokens from Commons/Color.qml), 32x32 icon slot,
  Nerd Font glyph fallback via omarchy-glyph hint, hero image strip
  for screenshot/image-path notifications, hover-pause progress bar,
  uses bar.fontFamily so all surfaces share one font.

Filtering and DND:
- transient hint and CLI-style senders (app_name in notify-send /
  omarchy-action) bypass history but still pop.
- DND only allows omarchy-action toasts and notify-send -u critical
  through; real-app urgency=critical (Discord, Slack, Vesktop) is
  silenced and lands in pending instead.
- Pending vs past split surfaced via tabs in the bar widget popup;
  past tab is auto-pruned at the 15-minute mark.
- Click-to-jump: notifications without a libnotify default action focus
  the matching Hyprland window via class lookup.

Shell host:
- shell.qml: generic first-party service loader (mirrors the existing
  noctalia-compat path) and an alias for the bar so plugins can read
  barSize / barHidden / position for anchoring.
- Commons/Color.qml: parses the theme's hyprland.conf for
  $activeBorderColor so notifications match Hyprland window borders;
  picks the explicit accent= key over the color4= alias.

Bar widget rebase (plugins/bar/widgets/notificationCenter.qml):
- Drops the chunk-1 stub server, binds count/dnd state to the service,
  hosts the history popup via PopupCard so it drops down from the
  notification glyph the same way Quick Settings does.
- Pending/Past tabs, dismiss-individual close X, mark-all-as-seen and
  clear-recent action buttons, theme-driven palette.

Quick Settings rework (plugins/bar/widgets/controlCenter.qml):
- DND tile binds directly to service.doNotDisturb for instant feedback.
- Drops the volume slider (already in audioPanel) and the no-op Theme
  tile; adds a Bluetooth toggle bound to Quickshell.Bluetooth.
- Bigger 44x44 wallet was scaled back to 32x32 for tighter rows.

Notification scripts (bin/omarchy-*):
- omarchy-notification-send: passes glyph as a custom hint instead of
  prepending to the summary; adds -a omarchy-action and -u urgency
  automatically; supports -e/--transient passthrough.
- User-action toasts in the capture / toggle / hyprland / default-*
  scripts and bindings/utilities.lua now tag themselves
  -a omarchy-action so DND treats them as intent-based bypass.
- omarchy-toggle-notification-silencing, omarchy-notification-dismiss,
  default/waybar/indicators/notification-silencing.sh, and the Hyprland
  comma-keybinds all route through omarchy-shell-ipc notifications.
- omarchy-capture-screenshot / -screenrecording set the image-path
  hint properly so the hero-image rendering kicks in.

Mako removal (migrations/1778743515.sh):
- pkill -x mako, systemctl --user stop mako.service, pacman -Rns mako
  (uninstalling deletes /usr/lib/systemd/user/mako.service so D-Bus
  activation can't respawn it). Removes ~/.config/mako/ and the legacy
  toggle file. Restarts quickshell so it claims the bus name.
- Drops mako from install/omarchy-base.packages, autostart.lua,
  install/config/theme.sh + toggles.sh, default/themed/mako.ini.tpl,
  default/mako/, the omarchy-menu Mako restart row, bin/omarchy
  GROUP_DESCRIPTIONS, the settings panel catalogue, and the
  default/omarchy-skill paths table.
- Removed scripts: bin/omarchy-restart-mako,
  bin/omarchy-style-corners-mako.
- bin/omarchy-style-corners summary updated; corner radius for the
  notification card reads ~/.local/state/omarchy/toggles/quickshell-menu.json
  alongside the rest of the shell.
This commit is contained in:
Ryan Hughes
2026-05-14 14:21:07 -04:00
parent a7f8f2495f
commit c101591284
47 changed files with 1780 additions and 413 deletions
-1
View File
@@ -47,7 +47,6 @@ GROUP_DESCRIPTIONS[hw]="Hardware detection and controls"
GROUP_DESCRIPTIONS[hyprland]="Hyprland window, monitor, and toggle controls"
GROUP_DESCRIPTIONS[install]="Optional software installers"
GROUP_DESCRIPTIONS[launch]="Application launchers"
GROUP_DESCRIPTIONS[mako]="Mako notification controls"
GROUP_DESCRIPTIONS[menu]="Omarchy menu commands"
GROUP_DESCRIPTIONS[migrate]="Migration runner"
GROUP_DESCRIPTIONS[notification]="Notification helpers"
+1 -1
View File
@@ -240,7 +240,7 @@ stop_screenrecording() {
ffmpeg -y -i "$filename" -ss 00:00:00.1 -vframes 1 -q:v 2 "$preview" -loglevel quiet 2>/dev/null
(
ACTION=$(notify-send "Screen recording saved" "Open with Super + Alt + , (or click this)" -t 10000 -i "${preview:-$filename}" -A "default=open")
ACTION=$(notify-send -a omarchy-action "Screen recording saved" "Open with Super + Alt + , (or click this)" -t 10000 --hint="string:image-path:${preview:-$filename}" -A "default=open")
[[ $ACTION == "default" ]] && mpv "$filename"
rm -f "$preview"
) &
+2 -2
View File
@@ -11,7 +11,7 @@ OUTPUT_DIR="${OMARCHY_SCREENSHOT_DIR:-${XDG_PICTURES_DIR:-$HOME/Pictures}}"
if [[ ! -d $OUTPUT_DIR ]]; then
mkdir -p "$OUTPUT_DIR"
notify-send "Created screenshot directory: $OUTPUT_DIR" -u normal -t 2000
notify-send -a omarchy-action "Created screenshot directory: $OUTPUT_DIR" -u normal -t 2000
fi
pkill slurp && exit 0
@@ -133,7 +133,7 @@ case "$PROCESSING" in
wl-copy <"$FILEPATH"
(
ACTION=$(notify-send "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" -t 10000 -i "$FILEPATH" -A "default=edit")
ACTION=$(notify-send -a omarchy-action "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" -t 10000 --hint="string:image-path:$FILEPATH" -A "default=edit")
[[ $ACTION == "default" ]] && open_editor "$FILEPATH"
) >/dev/null 2>&1 &
;;
+1 -1
View File
@@ -23,4 +23,4 @@ TEXT=$(grim -g "$SELECTION" - | tesseract stdin stdout --oem 1 --psm 6 -l "${OMA
[[ -z $TEXT ]] && exit 1
printf "%s" "$TEXT" | wl-copy
notify-send "󰴑 Copied text from selection to clipboard"
notify-send -a omarchy-action "󰴑 Copied text from selection to clipboard"
+1 -1
View File
@@ -37,4 +37,4 @@ 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
notify-send -u low "$glyph $name is now the default browser"
notify-send -a omarchy-action -u low "$glyph $name is now the default browser"
+1 -1
View File
@@ -27,4 +27,4 @@ esac
sed -i "s/^export EDITOR=.*/export EDITOR=$editor/" ~/.config/uwsm/default
export EDITOR="$editor"
notify-send -u low "$glyph $name is now the default editor" " Effective after logging out"
notify-send -a omarchy-action -u low "$glyph $name is now the default editor" " Effective after logging out"
+1 -1
View File
@@ -33,4 +33,4 @@ cat >~/.config/xdg-terminals.list <<EOF
$desktop_id
EOF
notify-send -u low "$glyph $name is now the default terminal"
notify-send -a omarchy-action -u low "$glyph $name is now the default terminal"
+2 -2
View File
@@ -55,11 +55,11 @@ if [[ -n $font_name ]]; then
omarchy-restart-swayosd
if pgrep -x ghostty; then
notify-send -u low " You must restart Ghostty to see font change"
notify-send -a omarchy-action -u low " You must restart Ghostty to see font change"
fi
if pgrep -x foot; then
notify-send -u low " You must restart Foot to see font change"
notify-send -a omarchy-action -u low " You must restart Foot to see font change"
fi
omarchy-hook font-set "$font_name"
+1 -1
View File
@@ -42,4 +42,4 @@ elif [[ -f $MONITOR_LUA ]] && grep -Eq '^hl\.monitor\(\{ output = "", mode = "pr
"$MONITOR_LUA"
fi
notify-send -u low "󰍹 Display scaling set to ${NEW_SCALE}x"
notify-send -a omarchy-action -u low "󰍹 Display scaling set to ${NEW_SCALE}x"
+2 -2
View File
@@ -42,7 +42,7 @@ on() {
if [[ -f $FLAG_SOURCE ]]; then
mkdir -p "$(dirname "$FLAG_FILE")"
cp "$FLAG_SOURCE" "$FLAG_FILE"
[[ -n $ENABLED_NOTIFICATION ]] && notify-send -u low "$ENABLED_NOTIFICATION"
[[ -n $ENABLED_NOTIFICATION ]] && notify-send -a omarchy-action -u low "$ENABLED_NOTIFICATION"
else
echo "Flag not found: $FLAG_NAME" >&2
exit 1
@@ -51,7 +51,7 @@ on() {
off() {
rm -f "$FLAG_FILE"
[[ -n $DISABLED_NOTIFICATION ]] && notify-send -u low "$DISABLED_NOTIFICATION"
[[ -n $DISABLED_NOTIFICATION ]] && notify-send -a omarchy-action -u low "$DISABLED_NOTIFICATION"
}
toggle() {
+1 -1
View File
@@ -12,4 +12,4 @@ esac
hyprctl eval "hl.workspace_rule({ workspace = \"$ACTIVE_WORKSPACE\", layout = \"$NEW_LAYOUT\" })" >/dev/null 2>&1 || \
hyprctl keyword workspace $ACTIVE_WORKSPACE, layout:$NEW_LAYOUT
notify-send -u low "󱂬 Workspace layout set to $NEW_LAYOUT"
notify-send -a omarchy-action -u low "󱂬 Workspace layout set to $NEW_LAYOUT"
+1 -1
View File
@@ -47,7 +47,7 @@ for m in $(hyprctl monitors -j | jq -r '.[] | .name'); do
hypr_exec "kitty --class=org.omarchy.screensaver --override font_size=18 --override window_padding_width=0 -e omarchy-screensaver"
;;
*)
notify-send -u low "✋ Screensaver only runs in Alacritty, Foot, Ghostty, or Kitty"
notify-send -a omarchy-action -u low "✋ Screensaver only runs in Alacritty, Foot, Ghostty, or Kitty"
;;
esac
done
+2 -7
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# omarchy:summary=Dismiss a mako notification on the basis of its summary. Used by the first-run notifications to dismiss them after clicking for action.
# omarchy:summary=Dismiss a notification by summary substring. Used by the first-run notifications to dismiss them after clicking for action.
# omarchy:args=<summary>
if (($# == 0)); then
@@ -8,9 +8,4 @@ if (($# == 0)); then
exit 1
fi
# Find the first notification whose 'summary' matches the regex in $1
notification_id=$(makoctl list | grep -F "$1" | head -n1 | sed -E 's/^Notification ([0-9]+):.*/\1/')
if [[ -n $notification_id ]]; then
makoctl dismiss -n $notification_id
fi
omarchy-shell-ipc notifications dismiss "$1" >/dev/null 2>&1 || true
+11 -5
View File
@@ -65,12 +65,18 @@ while (($# > 0)); do
fi
done
summary="$glyph $headline"
args+=("-u" "$urgency")
# Tag as a user-action toast so it pops through DND. See dnd-fix-plan.md.
args+=("-a" "omarchy-action" "-u" "$urgency")
# Glyph is now a custom hint the quickshell daemon renders into the icon
# slot, not prepended to the summary. Old mako-era formatting was glyph + 4
# spaces + headline, which leaves ugly whitespace in the new card design.
if [[ -n $glyph ]]; then
args+=("--hint=string:omarchy-glyph:$glyph")
fi
if [[ -n $description ]]; then
description=$(printf '%b' "$description" | sed 's/^/ /')
notify-send "${args[@]}" "$summary" "$description"
notify-send "${args[@]}" "$headline" "$description"
else
notify-send "${args[@]}" "$summary"
notify-send "${args[@]}" "$headline"
fi
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
# omarchy:summary=Reload mako configuration (used by the Omarchy theme switching).
makoctl reload
+1 -2
View File
@@ -1,6 +1,6 @@
#!/bin/bash
# omarchy:summary=Set Hyprland, Hyprlock, Mako, Walker, and Quickshell corners to sharp or round
# omarchy:summary=Set Hyprland, Hyprlock, Walker, and Quickshell (bar, menu, notifications) corners to sharp or round
# omarchy:args=<sharp|round>
# omarchy:examples=omarchy style corners round | omarchy style corners sharp
@@ -11,7 +11,6 @@ fi
omarchy-style-corners-hyprland "$1"
omarchy-style-corners-hyprlock "$1"
omarchy-style-corners-mako "$1"
omarchy-style-corners-walker "$1"
omarchy-style-corners-quickshell "$1"
-38
View File
@@ -1,38 +0,0 @@
#!/bin/bash
# omarchy:summary=Set or toggle rounded Mako notification corners
# omarchy:args=[toggle|sharp|round]
# omarchy:examples=omarchy style corners mako toggle | omarchy style corners mako round | omarchy style corners mako sharp
TOGGLES_CONFIG="$HOME/.local/state/omarchy/toggles/mako.ini"
set_radius() {
local radius="$1"
mkdir -p "$(dirname "$TOGGLES_CONFIG")"
touch "$TOGGLES_CONFIG"
if grep -q '^border-radius=' "$TOGGLES_CONFIG"; then
sed -i "s/^border-radius=.*/border-radius=$radius/" "$TOGGLES_CONFIG"
else
echo "border-radius=$radius" >>"$TOGGLES_CONFIG"
fi
}
case "${1:-toggle}" in
round) set_radius 6 ;;
sharp) set_radius 0 ;;
toggle)
if [[ -f $TOGGLES_CONFIG ]] && grep -q '^border-radius=6$' "$TOGGLES_CONFIG"; then
set_radius 0
else
set_radius 6
fi
;;
*)
echo "Usage: omarchy-style-corners-mako [toggle|sharp|round]"
exit 1
;;
esac
makoctl reload
-1
View File
@@ -58,7 +58,6 @@ omarchy-restart-terminal
omarchy-restart-hyprctl
omarchy-restart-btop
omarchy-restart-opencode
omarchy-restart-mako
omarchy-restart-helix
# Change app-specific themes
+2 -2
View File
@@ -19,9 +19,9 @@ FLAG="$HOME/.local/state/omarchy/toggles/$FLAG_NAME"
if [[ -f $FLAG ]]; then
rm $FLAG
[[ -n $DISABLED_NOTIFICATION ]] && notify-send -u low "$DISABLED_NOTIFICATION"
[[ -n $DISABLED_NOTIFICATION ]] && notify-send -a omarchy-action -u low "$DISABLED_NOTIFICATION"
else
mkdir -p "$(dirname $FLAG)"
touch $FLAG
[[ -n $ENABLED_NOTIFICATION ]] && notify-send -u low "$ENABLED_NOTIFICATION"
[[ -n $ENABLED_NOTIFICATION ]] && notify-send -a omarchy-action -u low "$ENABLED_NOTIFICATION"
fi
+2 -2
View File
@@ -4,10 +4,10 @@
if pgrep -x hypridle >/dev/null; then
pkill -x hypridle
notify-send -u low "󱫖 Stop locking computer when idle"
notify-send -a omarchy-action -u low "󱫖 Stop locking computer when idle"
else
uwsm-app -- hypridle >/dev/null 2>&1 &
notify-send -u low "󱫖 Now locking computer when idle"
notify-send -a omarchy-action -u low "󱫖 Now locking computer when idle"
fi
pkill -RTMIN+9 waybar
+2 -2
View File
@@ -23,10 +23,10 @@ restart_nightlighted_waybar() {
if [[ $CURRENT_TEMP == $OFF_TEMP ]]; then
hyprctl hyprsunset temperature $ON_TEMP
notify-send -u low " Nightlight screen temperature"
notify-send -a omarchy-action -u low " Nightlight screen temperature"
restart_nightlighted_waybar
else
hyprctl hyprsunset temperature $OFF_TEMP
notify-send -u low " Daylight screen temperature"
notify-send -a omarchy-action -u low " Daylight screen temperature"
restart_nightlighted_waybar
fi
+7 -9
View File
@@ -2,14 +2,12 @@
# omarchy:summary=Toggle notification do-not-disturb mode
makoctl mode -t do-not-disturb
state=$(omarchy-shell-ipc notifications toggleDnd 2>/dev/null || echo "")
if makoctl mode | grep -q 'do-not-disturb'; then
notify-send -u low "󰂛 Silenced notifications"
else
notify-send -u low "󰂚 Enabled notifications"
fi
case $state in
on) omarchy-notification-send "Silenced notifications" -g 󰂛 -e ;;
off) omarchy-notification-send "Enabled notifications" -g 󰂚 -e ;;
esac
pkill -RTMIN+10 waybar
OMARCHY_PATH=${OMARCHY_PATH:-$HOME/.local/share/omarchy}
quickshell ipc -p "$OMARCHY_PATH/default/quickshell/omarchy-shell" call bar refreshIndicators >/dev/null 2>&1 || true
pkill -RTMIN+10 waybar 2>/dev/null || true
omarchy-shell-ipc bar refreshIndicators >/dev/null 2>&1 || true
+1 -1
View File
@@ -24,5 +24,5 @@ if gum confirm "Install Voxtype + AI model (~150MB) to enable dictation?"; then
omarchy-restart-waybar
fi
omarchy-restart-quickshell
notify-send " Voxtype Dictation Ready" "Hold F9 to dictate (or toggle with Super + Ctrl + X)." -t 10000
notify-send -a omarchy-action " Voxtype Dictation Ready" "Hold F9 to dictate (or toggle with Super + Ctrl + X)." -t 10000
fi
+1 -1
View File
@@ -293,7 +293,7 @@ launch_windows() {
echo "Starting Windows VM..."
# Send desktop notification
notify-send " Starting Windows VM" " This can take 15-30 seconds" -t 15000
notify-send -a omarchy-action " Starting Windows VM" " This can take 15-30 seconds" -t 15000
if ! docker-compose -f "$COMPOSE_FILE" up -d 2>&1; then
echo "❌ Failed to start Windows VM!"
-1
View File
@@ -1,6 +1,5 @@
hl.on("hyprland.start", function()
hl.exec_cmd("uwsm-app -- hypridle")
hl.exec_cmd("uwsm-app -- mako")
hl.exec_cmd("omarchy-restart-quickshell")
hl.exec_cmd("uwsm-app -- fcitx5 --disable notificationitem")
hl.exec_cmd("uwsm-app -- swaybg -i ~/.config/omarchy/current/background -m fill")
+7 -7
View File
@@ -22,11 +22,11 @@ hl.bind("SUPER + BACKSPACE", hl.dsp.exec_cmd("omarchy-hyprland-window-transparen
hl.bind("SUPER + SHIFT + BACKSPACE", hl.dsp.exec_cmd("omarchy-hyprland-window-gaps-toggle"), { description = "Toggle window gaps" })
hl.bind("SUPER + CTRL + BACKSPACE", hl.dsp.exec_cmd("omarchy-hyprland-window-single-square-aspect-toggle"), { description = "Toggle single-window square aspect" })
hl.bind("SUPER + COMMA", hl.dsp.exec_cmd("makoctl dismiss"), { description = "Dismiss last notification" })
hl.bind("SUPER + SHIFT + COMMA", hl.dsp.exec_cmd("makoctl dismiss --all"), { description = "Dismiss all notifications" })
hl.bind("SUPER + COMMA", hl.dsp.exec_cmd("omarchy-shell-ipc notifications dismissOne"), { description = "Dismiss last notification" })
hl.bind("SUPER + SHIFT + COMMA", hl.dsp.exec_cmd("omarchy-shell-ipc notifications dismissAll"), { description = "Dismiss all notifications" })
hl.bind("SUPER + CTRL + COMMA", hl.dsp.exec_cmd("omarchy-toggle-notification-silencing"), { description = "Toggle silencing notifications" })
hl.bind("SUPER + ALT + COMMA", hl.dsp.exec_cmd("makoctl invoke"), { description = "Invoke last notification" })
hl.bind("SUPER + SHIFT + ALT + COMMA", hl.dsp.exec_cmd("makoctl restore"), { description = "Restore last notification" })
hl.bind("SUPER + ALT + COMMA", hl.dsp.exec_cmd("omarchy-shell-ipc notifications invokeLast"), { description = "Invoke last notification" })
hl.bind("SUPER + SHIFT + ALT + COMMA", hl.dsp.exec_cmd("omarchy-shell-ipc notifications showHistory"), { description = "Open notification history" })
hl.bind("SUPER + CTRL + I", hl.dsp.exec_cmd("omarchy-toggle-idle"), { description = "Toggle locking on idle" })
hl.bind("SUPER + CTRL + N", hl.dsp.exec_cmd("omarchy-toggle-nightlight"), { description = "Toggle nightlight" })
@@ -48,9 +48,9 @@ hl.bind("SUPER + CTRL + R", hl.dsp.exec_cmd("omarchy-menu reminder-set"), { desc
hl.bind("SUPER + CTRL + ALT + R", hl.dsp.exec_cmd("omarchy-reminder show"), { description = "Show reminders" })
hl.bind("SUPER + SHIFT + CTRL + R", hl.dsp.exec_cmd("omarchy-reminder clear"), { description = "Clear reminders" })
hl.bind("SUPER + CTRL + ALT + T", hl.dsp.exec_cmd([[notify-send -u low " $(date +"%A %H:%M · %d %B %Y · Week %V")"]]), { description = "Show time" })
hl.bind("SUPER + CTRL + ALT + B", hl.dsp.exec_cmd([[notify-send -u low "$(omarchy-battery-status)"]]), { description = "Show battery remaining" })
hl.bind("SUPER + CTRL + ALT + W", hl.dsp.exec_cmd([[notify-send -u low "$(omarchy-weather-status)"]]), { description = "Show weather" })
hl.bind("SUPER + CTRL + ALT + T", hl.dsp.exec_cmd([[notify-send -a omarchy-action -u low " $(date +"%A %H:%M · %d %B %Y · Week %V")"]]), { description = "Show time" })
hl.bind("SUPER + CTRL + ALT + B", hl.dsp.exec_cmd([[notify-send -a omarchy-action -u low "$(omarchy-battery-status)"]]), { description = "Show battery remaining" })
hl.bind("SUPER + CTRL + ALT + W", hl.dsp.exec_cmd([[notify-send -a omarchy-action -u low "$(omarchy-weather-status)"]]), { description = "Show weather" })
hl.bind("SUPER + CTRL + A", hl.dsp.exec_cmd("omarchy-launch-audio"), { description = "Audio controls" })
hl.bind("SUPER + CTRL + B", hl.dsp.exec_cmd("omarchy-launch-bluetooth"), { description = "Bluetooth controls" })
-38
View File
@@ -1,38 +0,0 @@
anchor=top-right
group-by=app-name,summary,body
default-timeout=5000
width=420
outer-margin=20
padding=10,15
border-size=2
max-icon-size=32
font=sans-serif 14px
[app-name=Spotify]
invisible=1
[mode=do-not-disturb]
invisible=true
[mode=do-not-disturb app-name=notify-send]
invisible=false
[urgency=critical]
default-timeout=0
layer=overlay
[summary~="Setup Wi-Fi"]
on-button-left=exec sh -c 'omarchy-notification-dismiss "Setup Wi-Fi"; omarchy-launch-wifi'
[summary~="Update System"]
on-button-left=exec sh -c 'omarchy-notification-dismiss "Update System"; omarchy-launch-floating-terminal-with-presentation omarchy-update'
[summary~="Learn Keybindings"]
on-button-left=exec sh -c 'omarchy-notification-dismiss "Learn Keybindings"; omarchy-menu-keybindings'
[summary~="Install Dictation with Voxtype"]
on-button-left=exec sh -c 'omarchy-notification-dismiss "Install Dictation with Voxtype"; omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install'
[summary~="Screenshot copied & saved"]
max-icon-size=80
format=<b>%s</b>\n%b
+2 -3
View File
@@ -3,7 +3,7 @@ name: omarchy
description: >
REQUIRED for end-user customization of Linux desktop, window manager, or system config.
Use when editing ~/.config/hypr/, ~/.config/waybar/, ~/.config/walker/,
~/.config/alacritty/, ~/.config/foot/, ~/.config/kitty/, ~/.config/ghostty/, ~/.config/mako/,
~/.config/alacritty/, ~/.config/foot/, ~/.config/kitty/, ~/.config/ghostty/,
or ~/.config/omarchy/. Triggers: Hyprland, window rules, animations, keybindings,
monitors, gaps, borders, blur, opacity, waybar, walker, terminal config, themes,
background, night light, idle, lock screen, screenshots, reminders, layer rules,
@@ -23,7 +23,7 @@ It is not for contributing to Omarchy source code.
**ALWAYS invoke this skill for end-user requests involving ANY of these:**
- Editing ANY file in `~/.config/hypr/` (window rules, animations, keybindings, monitors, etc.)
- Editing ANY file in `~/.config/waybar/`, `~/.config/walker/`, `~/.config/mako/`
- Editing ANY file in `~/.config/waybar/`, `~/.config/walker/`
- Editing terminal configs (alacritty, foot, kitty, ghostty)
- Editing ANY file in `~/.config/omarchy/`
- Window behavior, animations, opacity, blur, gaps, borders
@@ -79,7 +79,6 @@ Omarchy is built on:
| **Waybar** | Status bar | `~/.config/waybar/` |
| **Walker** | App launcher | `~/.config/walker/` |
| **Alacritty/Foot/Kitty/Ghostty** | Terminals | `~/.config/<terminal>/` |
| **Mako** | Notifications | `~/.config/mako/` |
| **SwayOSD** | On-screen display | `~/.config/swayosd/` |
## Command Discovery
+1 -2
View File
@@ -77,7 +77,7 @@
"trigger.toggle.screensaver": {"icon":"󱄄","label":"Screensaver","action":"omarchy-toggle-screensaver"},
"trigger.toggle.nightlight": {"icon":"󰔎","label":"Nightlight","keywords":"night light hyprsunset","action":"omarchy-toggle-nightlight"},
"trigger.toggle.idle-lock": {"icon":"󱫖","label":"Idle Lock","keywords":"hypridle","action":"omarchy-toggle-idle"},
"trigger.toggle.notifications": {"icon":"󰂛","label":"Notifications","keywords":"mako silence","action":"omarchy-toggle-notification-silencing"},
"trigger.toggle.notifications": {"icon":"󰂛","label":"Notifications","keywords":"silence dnd","action":"omarchy-toggle-notification-silencing"},
"trigger.toggle.top-bar": {"icon":"󰍜","label":"Bar","keywords":"top bar quickshell","action":"omarchy-toggle-bar"},
"trigger.toggle.workspace-layout": {"icon":"󱂬","label":"Workspace Layout","action":"omarchy-hyprland-workspace-layout-toggle"},
"trigger.toggle.window-gaps": {"icon":"","label":"Window Gaps","action":"omarchy-hyprland-window-gaps-toggle"},
@@ -311,7 +311,6 @@
"update.channel.dev": {"icon":"🔴","label":"Dev","keywords":"development","action":"present_terminal 'omarchy-channel-set dev'"},
"update.process.hypridle": {"icon":"","label":"Hypridle","keywords":"restart","action":"omarchy-restart-hypridle"},
"update.process.hyprsunset": {"icon":"","label":"Hyprsunset","keywords":"restart","action":"omarchy-restart-hyprsunset"},
"update.process.mako": {"icon":"󰎟","label":"Mako","keywords":"restart notifications","action":"omarchy-restart-mako"},
"update.process.swayosd": {"icon":"","label":"Swayosd","keywords":"restart","action":"omarchy-restart-swayosd"},
"update.process.walker": {"icon":"󰌧","label":"Walker","keywords":"restart launcher","action":"omarchy-restart-walker"},
"update.process.quickshell": {"icon":"󰍜","label":"Quickshell","keywords":"restart bar shell","action":"omarchy-restart-quickshell"},
@@ -17,6 +17,11 @@ QtObject {
property color background: "#101315"
property color accent: "#cacccc"
property color urgent: "#a55555"
// The theme's Hyprland active border color (`$activeBorderColor` in the
// theme's hyprland.conf). Most themes set this to the accent; some themes
// (e.g. aether, oodle) override it to a neutral. Streamed from the theme
// file alongside colors.toml so notification surfaces stay in sync.
property color border: accent
// Noctalia palette tokens. We map them onto our theme colors.
readonly property color mPrimary: accent
@@ -91,14 +96,32 @@ QtObject {
function loadTheme(raw) {
var lines = String(raw || "").split("\n")
var foundAccent = false
var color4Value = ""
for (var i = 0; i < lines.length; i++) {
var match = lines[i].match(/^\s*([A-Za-z0-9_-]+)\s*=\s*["']?(#[0-9A-Fa-f]{6})/)
if (!match) continue
if (match[1] === "foreground") foreground = match[2]
else if (match[1] === "background") background = match[2]
else if (match[1] === "color4" || match[1] === "accent") accent = match[2]
else if (match[1] === "red") urgent = match[2]
// Prefer the explicit `accent` key; only fall back to color4 when the
// theme doesn't define a separate accent. Aether/oodle/etc define both,
// and color4 appears later in the file so the old single-property
// approach was clobbering accent with color4 (#8274fd purple).
else if (match[1] === "accent") { accent = match[2]; foundAccent = true }
else if (match[1] === "color4") color4Value = match[2]
else if (match[1] === "red" || match[1] === "color1") urgent = match[2]
}
if (!foundAccent && color4Value.length > 0) accent = color4Value
}
// Parse `$activeBorderColor = rgb(XXXXXX)` from the theme's hyprland.conf.
function loadHyprlandTheme(raw) {
var match = String(raw || "").match(/\$activeBorderColor\s*=\s*rgba?\(([0-9A-Fa-f]{6,8})\)/)
if (!match) { border = accent; return }
var hex = match[1]
// rgba() in hyprland is AARRGGBB; strip the AA prefix and use the RGB.
if (hex.length === 8) hex = hex.substring(2)
border = "#" + hex
}
// `omarchy-theme-set` recreates the theme/ directory via rm+mv, which kills
@@ -116,6 +139,14 @@ QtObject {
path: Quickshell.env("HOME") + "/.config/omarchy/current/theme.name"
watchChanges: true
printErrors: false
onFileChanged: themeColorsFile.reload()
onFileChanged: { themeColorsFile.reload(); themeHyprlandFile.reload() }
}
property FileView themeHyprlandFile: FileView {
id: themeHyprlandFile
path: Quickshell.env("HOME") + "/.config/omarchy/current/theme/hyprland.conf"
watchChanges: true
printErrors: false
onLoaded: root.loadHyprlandTheme(text())
onFileChanged: reload()
}
}
@@ -309,7 +309,7 @@ Item {
"networkPanel": { displayName: "Network", description: "Wi-Fi list and connection state", category: "Network", allowMultiple: false },
"bluetoothPanel": { displayName: "Bluetooth", description: "Bluetooth device list with connect/disconnect", category: "Network", allowMultiple: false },
"calendar": { displayName: "Calendar", description: "Clock with month-grid popup", category: "Time", allowMultiple: false, settingsForm: "calendarSettings" },
"notificationCenter": { displayName: "Notification center", description: "Recent notifications + DND (replaces mako)", category: "Status", allowMultiple: false },
"notificationCenter": { displayName: "Notification center", description: "Recent notifications + DND", category: "Status", allowMultiple: false },
"systemStats": { displayName: "System stats", description: "CPU icon — hover for graphs, click to open btop", category: "System", allowMultiple: false },
"weatherFlyout": { displayName: "Weather", description: "Weather pill with detail popup", category: "Info", allowMultiple: false },
"idleInhibitor": { displayName: "Keep awake", description: "Toggle idle inhibitor", category: "System", allowMultiple: false },
@@ -1,5 +1,6 @@
import QtQuick
import Quickshell
import Quickshell.Bluetooth
import Quickshell.Io
import Quickshell.Services.Pipewire
import Quickshell.Services.UPower
@@ -25,15 +26,22 @@ Item {
return value === undefined || value === null ? fallback : value
}
readonly property var sink: Pipewire.defaultAudioSink
readonly property real currentVolume: sink && sink.audio && isFinite(sink.audio.volume) ? sink.audio.volume : 0
readonly property bool sinkMuted: sink && sink.audio ? sink.audio.muted : false
PwObjectTracker { objects: root.sink ? [root.sink] : [] }
// Volume controls live in the audioPanel bar widget, so the quick-settings
// popup just hosts brightness + toggles. Bluetooth adapter status is
// exposed via Quickshell.Bluetooth.
readonly property var btAdapter: Bluetooth.defaultAdapter
readonly property bool btEnabled: btAdapter ? btAdapter.enabled : false
property int currentBrightness: -1
property int pendingBrightness: -1
property bool dndActive: false
// DND state is bound straight to the notifications service so toggling
// from the quick-settings tile or the bar widget reflects instantly.
readonly property var hostShell: bar && bar.shell ? bar.shell : null
readonly property var notificationService: hostShell && typeof hostShell.firstPartyServiceFor === "function"
? hostShell.firstPartyServiceFor("omarchy.notifications")
: null
readonly property bool dndActive: notificationService ? notificationService.doNotDisturb : false
property bool idleInhibited: false
property bool nightLightActive: false
property bool nightLightAvailable: false
@@ -42,16 +50,6 @@ Item {
readonly property bool powerProfileAvailable: PowerProfiles.hasPerformanceProfile || PowerProfiles.profile === PowerProfile.PowerSaver || PowerProfiles.profile === PowerProfile.Balanced
readonly property int currentProfile: PowerProfiles.profile
function setVolume(value) {
if (!sink || !sink.audio) return
sink.audio.volume = Math.max(0, Math.min(1, value))
}
function toggleMute() {
if (!sink || !sink.audio) return
sink.audio.muted = !sink.audio.muted
}
function setBrightness(percent) {
var clamped = Math.max(1, Math.min(100, Math.round(percent)))
currentBrightness = clamped
@@ -61,7 +59,6 @@ Item {
function refresh() {
if (!brightnessProc.running) brightnessProc.running = true
if (!dndProc.running) dndProc.running = true
if (!idleProc.running) idleProc.running = true
if (!nightLightProc.running) nightLightProc.running = true
if (!themeProc.running) themeProc.running = true
@@ -98,15 +95,6 @@ Item {
}
}
Process {
id: dndProc
command: ["bash", "-lc", "if command -v makoctl >/dev/null && makoctl mode 2>/dev/null | grep -q '^do-not-disturb$'; then echo on; else echo off; fi"]
stdout: StdioCollector {
waitForEnd: true
onStreamFinished: root.dndActive = String(text || "").trim() === "on"
}
}
Process {
id: idleProc
command: ["bash", "-lc", "if pgrep -x hypridle >/dev/null 2>&1; then echo running; else echo inhibited; fi"]
@@ -190,49 +178,12 @@ Item {
Column {
width: parent.width
spacing: 10
visible: root.sink !== null
visible: root.currentBrightness >= 0
Row {
width: parent.width
spacing: 8
Common.PillButton {
iconText: root.sinkMuted ? "󰝟" : "󰕾"
foreground: root.bar.foreground
horizontalPadding: 8
verticalPadding: 6
iconSize: 16
onClicked: root.toggleMute()
}
Common.Slider {
bar: root.bar
width: parent.width - 90
value: root.currentVolume
minimum: 0
maximum: 1
step: 0.05
anchors.verticalCenter: parent.verticalCenter
onMoved: function(v) { root.setVolume(v) }
onReleased: function(v) { root.setVolume(v) }
}
Text {
text: Math.round(root.currentVolume * 100) + "%"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 11
anchors.verticalCenter: parent.verticalCenter
width: 32
horizontalAlignment: Text.AlignRight
}
}
Row {
width: parent.width
spacing: 8
visible: root.currentBrightness >= 0
Common.PillButton {
iconText: "󰃠"
foreground: root.bar.foreground
@@ -284,7 +235,10 @@ Item {
title: "Do Not Disturb"
subtitle: root.dndActive ? "On" : "Off"
active: root.dndActive
onClicked: { root.run("omarchy-toggle-notification-silencing"); dndProc.running = true }
onClicked: {
if (!root.notificationService) return
root.notificationService.setDoNotDisturb(!root.notificationService.doNotDisturb)
}
}
Tile {
@@ -308,10 +262,15 @@ Item {
Tile {
width: (tileGrid.width - tileGrid.columnSpacing) / 2
glyph: "󰔎"
title: "Theme"
subtitle: root.themeName || "—"
onClicked: { root.run("omarchy-menu themes"); root.popupOpen = false }
glyph: root.btEnabled ? "󰂯" : "󰂲"
title: "Bluetooth"
subtitle: !root.btAdapter ? "—" : (root.btEnabled ? "On" : "Off")
active: root.btEnabled
tileEnabled: root.btAdapter !== null
onClicked: {
if (!root.btAdapter) return
root.btAdapter.enabled = !root.btAdapter.enabled
}
}
}
@@ -1,6 +1,7 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Services.Notifications
import qs.Commons
import "../common" as Common
Item {
@@ -11,80 +12,46 @@ Item {
property var settings: ({})
property bool popupOpen: false
function closePopout() { popupOpen = false }
property var stored: []
property bool dnd: false
readonly property int count: stored.length
// Always default to the pending tab when there's anything unseen, no
// matter how the popup was opened (click, keybind/IPC, or the closePopout
// 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 && typeof hostShell.firstPartyServiceFor === "function"
? hostShell.firstPartyServiceFor("omarchy.notifications")
: null
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 (count > 0) return "󱅫"
if (pendingCount > 0) return "󱅫"
return "󰂚"
}
property bool replaceMako: settings && settings.replaceMako === true
Loader {
active: root.replaceMako
sourceComponent: serverComponent
}
Component {
id: serverComponent
NotificationServer {
id: server
keepOnReload: false
bodySupported: true
actionsSupported: true
imageSupported: true
onNotification: function(notification) {
if (root.dnd) {
notification.expire()
return
}
notification.tracked = true
var snapshot = {
id: notification.id,
app: notification.appName,
summary: notification.summary,
body: notification.body,
time: new Date(),
ref: notification
}
var next = root.stored.slice()
next.unshift(snapshot)
if (next.length > 30) next.pop()
root.stored = next
}
}
}
function dismiss(index) {
var item = stored[index]
if (item && item.ref && !item.ref.closed) item.ref.dismiss()
var next = stored.slice()
next.splice(index, 1)
stored = next
}
function clearAll() {
for (var i = 0; i < stored.length; i++) {
if (stored[i].ref && !stored[i].ref.closed) stored[i].ref.dismiss()
}
stored = []
}
function relativeTime(date) {
if (!date) return ""
var diff = (Date.now() - date.getTime()) / 1000
if (diff < 60) return "just now"
if (diff < 3600) return Math.floor(diff / 60) + "m"
if (diff < 86400) return Math.floor(diff / 3600) + "h"
return Math.floor(diff / 86400) + "d"
}
// 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: Qt.rgba(Color.foreground.r, Color.foreground.g, Color.foreground.b, 0.18)
readonly property color colSurface: Qt.rgba(Color.foreground.r, Color.foreground.g, Color.foreground.b, 0.06)
readonly property color colAccent: Color.accent
readonly property int cardRadius: notificationService ? notificationService.cornerRadius : 10
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
@@ -94,148 +61,315 @@ Item {
anchors.fill: parent
bar: root.bar
text: root.icon
active: root.count > 0 && !root.dnd
tooltipText: root.dnd ? "Do Not Disturb" : (root.count > 0 ? root.count + " notifications" : "No notifications")
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) root.dnd = !root.dnd
else root.popupOpen = !root.popupOpen
if (b === Qt.RightButton) {
if (root.notificationService) {
root.notificationService.setDoNotDisturb(!root.notificationService.doNotDisturb)
}
} else {
root.popupOpen = !root.popupOpen
}
}
}
// Service-side IPC (omarchy-shell-ipc notifications showHistory) flips
// historyOpenRequested; we toggle our local popup state from here so the
// keybind path lands in the same PopupCard the click path uses.
Connections {
target: root.notificationService
ignoreUnknownSignals: true
function onHistoryOpenRequested() {
root.popupOpen = true
}
}
Common.PopupCard {
id: popup
anchorItem: button
owner: root
bar: root.bar
owner: root
open: root.popupOpen
contentWidth: 340
contentHeight: Math.min(420, listColumn.implicitHeight + 60)
contentWidth: 440
contentHeight: 540
Column {
id: listColumn
ColumnLayout {
anchors.fill: parent
spacing: 8
spacing: 10
Row {
width: parent.width
// ----------------------------------------- header
RowLayout {
Layout.fillWidth: true
spacing: 8
Text {
text: "Notifications"
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 13
font.family: root.bar ? root.bar.fontFamily : ""
color: root.colForeground
font.pixelSize: 14
font.bold: true
anchors.verticalCenter: parent.verticalCenter
}
Item { width: parent.width - 220; height: 1 }
Item { Layout.fillWidth: true }
Common.PillButton {
iconText: root.dnd ? "󰂛" : ""
text: root.dnd ? "DND" : ""
foreground: root.bar.foreground
horizontalPadding: 8
verticalPadding: 4
active: root.dnd
onClicked: root.dnd = !root.dnd
}
Rectangle {
id: dndPill
Layout.preferredHeight: 24
Layout.preferredWidth: dndLabel.implicitWidth + dndGlyph.implicitWidth + 18
radius: Math.min(12, root.cardRadius + 6)
color: dndOn ? root.colAccent : root.colSurface
border.color: dndOn ? root.colAccent : root.colBorder
border.width: 1
Common.PillButton {
iconText: "󰎟"
foreground: root.bar.foreground
horizontalPadding: 8
verticalPadding: 4
enabled: root.count > 0
opacity: enabled ? 1 : 0.4
onClicked: root.clearAll()
readonly property bool dndOn: !!root.notificationService && root.notificationService.doNotDisturb
Row {
anchors.centerIn: parent
spacing: 4
Text {
id: dndGlyph
text: dndPill.dndOn ? "󰂛" : "󰂚"
font.family: root.bar ? root.bar.fontFamily : ""
color: dndPill.dndOn ? Color.background : root.colDim
font.pixelSize: 12
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: 10
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)
}
}
}
Flickable {
width: parent.width
height: Math.min(320, contentHeight)
contentHeight: feedColumn.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
// ----------------------------------------- tabs
RowLayout {
Layout.fillWidth: true
spacing: 0
Column {
id: feedColumn
width: parent.width
spacing: 4
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
Repeater {
model: root.stored
Layout.fillWidth: true
Layout.preferredHeight: 30
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: 12
font.bold: parent.isActive
}
Rectangle {
required property var modelData
required property int index
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 2
color: parent.isActive ? root.colAccent : root.colBorder
opacity: parent.isActive ? 1 : 0.4
}
width: feedColumn.width
radius: 4
color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.06)
implicitHeight: notifContent.implicitHeight + 16
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.activeTab = modelData.key
}
}
}
}
Column {
id: notifContent
// ----------------------------------------- action row
RowLayout {
Layout.fillWidth: true
visible: (root.activeTab === "pending" && root.pendingCount > 0)
|| (root.activeTab === "past" && root.pastCount > 0)
spacing: 8
Item { Layout.fillWidth: true }
Rectangle {
Layout.preferredWidth: actionLabel.implicitWidth + 16
Layout.preferredHeight: 22
radius: Math.min(6, root.cardRadius)
color: actionArea.containsMouse ? root.colBorder : "transparent"
border.color: root.colBorder
border.width: 1
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: 10
}
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: 8
readonly property bool onPending: root.activeTab === "pending"
model: !root.notificationService ? null
: (onPending ? root.notificationService.pendingModel : root.notificationService.pastModel)
visible: count > 0
delegate: Rectangle {
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 : appIcon
readonly property bool hasIcon: !hasMedia && smallIconSource.length > 0
width: listView.width
implicitHeight: rowContent.implicitHeight + 20
radius: root.cardRadius
color: "transparent"
border.color: root.colBorder
border.width: 1
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: 12
anchors.rightMargin: 12
spacing: 10
Item {
Layout.preferredWidth: 32
Layout.preferredHeight: 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
anchors.margins: 8
spacing: 2
source: rowCard.hasMedia ? rowCard.image : rowCard.smallIconSource
fillMode: rowCard.hasMedia ? Image.PreserveAspectCrop : Image.PreserveAspectFit
sourceSize.width: 32 * Screen.devicePixelRatio
sourceSize.height: 32 * Screen.devicePixelRatio
asynchronous: true
smooth: true
}
}
Row {
width: parent.width
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
text: modelData ? (modelData.app || "App") : ""
color: Qt.darker(root.bar.foreground, 1.4)
font.family: root.bar.fontFamily
font.pixelSize: 10
font.bold: true
elide: Text.ElideRight
width: parent.width - timeText.implicitWidth - dismissBtn.width - 12
}
Text {
Layout.fillWidth: true
visible: rowCard.summary.length > 0
text: rowCard.summary
font.family: root.bar ? root.bar.fontFamily : ""
color: root.colForeground
font.pixelSize: 13
font.bold: true
wrapMode: Text.WordWrap
elide: Text.ElideRight
maximumLineCount: 1
}
Text {
id: timeText
text: modelData ? root.relativeTime(modelData.time) : ""
color: Qt.darker(root.bar.foreground, 1.6)
font.family: root.bar.fontFamily
font.pixelSize: 10
}
Text {
Layout.fillWidth: true
visible: rowCard.body.length > 0
text: String(rowCard.body).replace(/<img[^>]*>/gi, "")
font.family: root.bar ? root.bar.fontFamily : ""
textFormat: Text.PlainText
color: root.colDim
font.pixelSize: 11
wrapMode: Text.WordWrap
elide: Text.ElideRight
maximumLineCount: 2
}
}
Item { width: 6; height: 1 }
Rectangle {
Layout.preferredWidth: 18
Layout.preferredHeight: 18
Layout.alignment: Qt.AlignVCenter
radius: Math.min(4, root.cardRadius)
color: rowCloseArea.containsMouse ? root.colBorder : "transparent"
Common.PillButton {
id: dismissBtn
iconText: "󰅖"
foreground: root.bar.foreground
horizontalPadding: 4
verticalPadding: 0
iconSize: 10
onClicked: root.dismiss(index)
}
}
Text {
anchors.centerIn: parent
text: ""
font.family: root.bar ? root.bar.fontFamily : ""
color: root.colDim
font.pixelSize: 11
}
Text {
text: modelData ? (modelData.summary || "") : ""
color: root.bar.foreground
font.family: root.bar.fontFamily
font.pixelSize: 11
font.bold: true
wrapMode: Text.WordWrap
width: parent.width
}
Text {
visible: modelData && modelData.body !== ""
text: modelData ? (modelData.body || "") : ""
color: Qt.darker(root.bar.foreground, 1.2)
font.family: root.bar.fontFamily
font.pixelSize: 10
wrapMode: Text.WordWrap
width: parent.width
maximumLineCount: 3
elide: Text.ElideRight
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)
}
}
}
@@ -243,12 +377,36 @@ Item {
}
}
Text {
visible: root.stored.length === 0
text: root.dnd ? "Do Not Disturb is on" : "Nothing new"
color: Qt.darker(root.bar.foreground, 1.5)
font.family: root.bar.fontFamily
font.pixelSize: 11
// ----------------------------------------- empty state
Item {
Layout.fillWidth: true
Layout.fillHeight: true
visible: listView.count === 0
ColumnLayout {
anchors.centerIn: parent
spacing: 6
Text {
Layout.alignment: Qt.AlignHCenter
text: "󰂚"
font.family: root.bar ? root.bar.fontFamily : ""
color: root.colBorder
font.pixelSize: 36
}
Text {
Layout.alignment: Qt.AlignHCenter
text: root.activeTab === "pending"
? "Nothing waiting for you"
: "Nothing recent"
font.family: root.bar ? root.bar.fontFamily : ""
? "Nothing waiting for you"
: "No past notifications"
color: root.colDim
font.pixelSize: 12
}
}
}
}
}
@@ -0,0 +1,902 @@
// Notification service. Adapted from noctalia-shell (MIT) and
// DankMaterialShell (MIT). Original implementations:
// https://github.com/noctalia-dev/noctalia-shell
// https://github.com/AvengeMedia/DankMaterialShell
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import Quickshell.Services.Notifications
import qs.Commons
import "components"
Item {
id: service
// Injected by omarchy-shell (the first-party service loader).
property string omarchyPath: ""
property var shell: null
property var manifest: null
readonly property string home: Quickshell.env("HOME")
readonly property string cacheDir: home + "/.cache/omarchy/"
readonly property string historyPath: cacheDir + "notifications.json"
readonly property string imageCacheDir: cacheDir + "notification-images/"
readonly property string styleStatePath: home + "/.local/state/omarchy/toggles/quickshell-menu.json"
// Optional per-theme override file. Themes that want notification colors
// to diverge from the rest of the palette can ship this file. Schema:
// {
// "borderColor": "#hex",
// "backgroundColor": "#hex",
// "textColor": "#hex",
// "countdownColor": "#hex" // progress bar at bottom of popup
// }
// All keys optional; unset keys fall back to the live Color.* tokens.
readonly property string themeOverridePath: home + "/.config/omarchy/current/theme/notifications.json"
// Corner radius is shared with omarchy-shell menu and settings panel —
// `omarchy style corners <sharp|round>` writes this file once and every
// surface reads it. Default 10 keeps the popup looking like it did pre-toggle.
property int cornerRadius: 10
// Surfaces anchor relative to the omarchy bar so popups and history land
// alongside the other shell panels rather than on top of the bar itself.
// Falls back to the bar's default size (26 horizontal / 28 vertical) when
// shell.bar isn't reachable so the popup never lands on top of the bar.
readonly property string barPosition: shell && shell.barConfig ? String(shell.barConfig.position || "top") : "top"
readonly property bool barVertical: barPosition === "left" || barPosition === "right"
readonly property int defaultBarSize: barVertical ? 28 : 26
readonly property int liveBarSize: shell && shell.bar && !shell.bar.barHidden ? Math.max(0, shell.bar.barSize) : defaultBarSize
readonly property int barClearance: liveBarSize + 12
function loadStyleState(raw) {
try {
var parsed = JSON.parse(raw || "{}")
var n = Number(parsed.radius)
cornerRadius = isFinite(n) && n >= 0 ? n : 10
} catch (e) {
cornerRadius = 10
}
}
FileView {
path: service.styleStatePath
watchChanges: true
printErrors: false
onLoaded: service.loadStyleState(text())
onFileChanged: reload()
}
// ----------------------------------------------- per-theme color overrides
property string overrideBorder: ""
property string overrideBackground: ""
property string overrideText: ""
property string overrideCountdown: ""
readonly property color effectiveBorder: overrideBorder.length > 0 ? overrideBorder : Color.border
readonly property color effectiveBackground: overrideBackground.length > 0 ? overrideBackground : Color.background
readonly property color effectiveText: overrideText.length > 0 ? overrideText : Color.foreground
readonly property color effectiveCountdown: overrideCountdown.length > 0 ? overrideCountdown : Color.accent
function loadThemeOverride(raw) {
try {
var parsed = JSON.parse(raw || "{}")
overrideBorder = typeof parsed.borderColor === "string" ? parsed.borderColor : ""
overrideBackground = typeof parsed.backgroundColor === "string" ? parsed.backgroundColor : ""
overrideText = typeof parsed.textColor === "string" ? parsed.textColor : ""
overrideCountdown = typeof parsed.countdownColor === "string" ? parsed.countdownColor : ""
} catch (e) {
overrideBorder = ""
overrideBackground = ""
overrideText = ""
overrideCountdown = ""
}
}
FileView {
path: service.themeOverridePath
watchChanges: true
printErrors: false
onLoaded: service.loadThemeOverride(text())
onLoadFailed: service.loadThemeOverride("")
onFileChanged: reload()
}
// Fired by IPC (`omarchy-shell-ipc notifications showHistory`) so the
// bar widget can drop its PopupCard from the same anchor a click would.
signal historyOpenRequested()
// PersistentProperties handles in-process QML reloads. The on-disk
// notifications.json file is the cross-restart backstop — its `dnd` key
// is hydrated into persisted.doNotDisturb on startup and written back via
// the same debounced save timer used for history entries.
PersistentProperties {
id: persisted
reloadableId: "omarchy-notifications"
property bool doNotDisturb: false
onDoNotDisturbChanged: {
// Suppress the write that load-time hydration would otherwise trigger.
if (service._hydrating) return
service.scheduleHistorySave()
}
}
// Guards onDoNotDisturbChanged while we're hydrating from disk so the
// hydration assignment doesn't immediately schedule a write-back.
property bool _hydrating: false
readonly property alias doNotDisturb: persisted.doNotDisturb
function setDoNotDisturb(value) {
persisted.doNotDisturb = !!value
}
// popupModel feeds the on-screen toast stack.
// pendingModel = notifications received but not yet "seen" by the user.
// Anything DND-suppressed lands here and stays there until
// the user reviews it; anything that pops up also lives
// here until the popup dismisses, then moves to pastModel.
// pastModel = notifications the user has already seen on-screen.
// Surfaced under the Past tab in the history panel.
//
// Aliased as properties so the bar widget and HistoryPanel (outside this
// Item's id scope) can bind to them. QML ids aren't visible to external
// consumers without the alias.
property alias popupModel: popupModel
property alias pendingModel: pendingModel
property alias pastModel: pastModel
ListModel { id: popupModel }
ListModel { id: pendingModel }
ListModel { id: pastModel }
readonly property int historyCap: 100
function durationFor(urgency) {
if (urgency === NotificationUrgency.Critical) return 0
if (urgency === NotificationUrgency.Low) return 3000
return 6000
}
// DND bypass: only let through notifications we trust to be intentional
// and rare.
// - omarchy-action: a user-action confirmation toast ("Theme changed",
// "Screenshot saved"). The user JUST did something — their feedback
// should show.
// - urgency=critical AND app_name=notify-send: bare-CLI emergency alerts
// (omarchy-battery-monitor uses this for low-battery; install-time
// scripts for wifi setup, etc.). Trusted because it's almost always
// omarchy or system shell scripts — chat apps set app_name to
// their brand (Discord/Slack/Vesktop) which falls outside this rule.
function shouldBypassDnd(notification) {
var appName = String(notification.appName || "")
if (appName === "omarchy-action") return true
if (appName === "notify-send" && notification.urgency === NotificationUrgency.Critical) return true
return false
}
function snapshotOf(notification) {
var glyph = ""
try {
if (notification.hints && typeof notification.hints["omarchy-glyph"] === "string") {
glyph = notification.hints["omarchy-glyph"]
}
} catch (e) { glyph = "" }
return {
id: notification.id,
originalId: notification.id,
app: notification.appName || "",
appIcon: notification.appIcon || "",
summary: notification.summary || "",
body: notification.body || "",
image: notification.image || "",
glyph: glyph,
urgency: notification.urgency,
timestamp: Date.now(),
ref: notification
}
}
function handleNotification(notification) {
// Without `tracked = true` the Notification object is destroyed as soon
// as this signal handler returns, which would null out the `ref` we just
// captured for the popup card.
notification.tracked = true
var snapshot = snapshotOf(notification)
// History is for notifications from real apps (Slack, Discord, mailer,
// etc.) — things the user might want to look back at. Skip the pending
// / past bookkeeping when:
// - the freedesktop `transient` hint is set ("popup only, don't store")
// - app_name is "notify-send" (the CLI default — means the sender
// didn't bother declaring an identity, so it's almost certainly
// ephemeral test/feedback noise)
// - app_name is "omarchy-action" (omarchy's own user-action
// confirmation toasts — the user just triggered them, they don't
// need to be archived)
var transient = false
try {
transient = !!(notification.hints && notification.hints["transient"])
} catch (e) { transient = false }
var appName = String(notification.appName || "")
var ephemeralApp = appName === "notify-send" || appName === "omarchy-action"
if (transient || ephemeralApp) {
if (service.doNotDisturb && !shouldBypassDnd(notification)) {
notification.tracked = false
return
}
Qt.callLater(function() { popupModel.insert(0, snapshot) })
return
}
// Pending first, unconditionally. DND only suppresses the toast — the
// record still has to land somewhere the user can review later.
addToPending(snapshot)
// Kick off a copy of any /tmp screenshot into the persistent image cache.
// The cp races the popup; the popup keeps the original path so it always
// renders, and the history row gets rewritten to the cached path once
// cp.exits.
maybeCacheImage(snapshot)
// DND bypass rules — see ~/Work/omarchy/dnd-fix-plan.md. The pending
// entry already captured this notification above; we just decide here
// whether to also pop a toast. Chat apps abuse urgency=critical to
// force visibility, so critical alone isn't enough — we also require
// the sender to be CLI-style. See shouldBypassDnd().
if (service.doNotDisturb && !shouldBypassDnd(notification)) {
notification.tracked = false
return
}
// Qt.callLater avoids "QV4::Object::insertMember" crashes when a
// Repeater is mid-incubation while we mutate its model — see noctalia
// NotificationService.qml ~L307.
Qt.callLater(function() {
popupModel.insert(0, snapshot)
})
}
function addToPending(snapshot) {
Qt.callLater(function() {
pendingModel.insert(0, snapshot)
while (pendingModel.count > service.historyCap) {
pendingModel.remove(pendingModel.count - 1)
}
scheduleHistorySave()
})
}
// Find a pending entry by its libnotify id and move it to pastModel. Called
// when a popup naturally dismisses (timer expired or user clicked X / the
// default action) — the user is assumed to have seen it.
function markSeenByOriginalId(originalId) {
Qt.callLater(function() {
for (var i = 0; i < pendingModel.count; i++) {
var entry = pendingModel.get(i)
if (!entry || entry.originalId !== originalId) continue
var snapshot = service.snapshotFromRow(entry)
pendingModel.remove(i)
pastModel.insert(0, snapshot)
while (pastModel.count > service.historyCap) {
pastModel.remove(pastModel.count - 1)
}
scheduleHistorySave()
return
}
})
}
// Copy a ListModel row into a plain JS object so we can re-insert it into
// a different model without sharing references.
function snapshotFromRow(row) {
return {
id: row.id,
originalId: row.originalId,
app: row.app,
appIcon: row.appIcon,
summary: row.summary,
body: row.body,
image: row.image,
glyph: row.glyph || "",
urgency: row.urgency,
timestamp: row.timestamp
}
}
function markAllSeen() {
Qt.callLater(function() {
while (pendingModel.count > 0) {
var entry = pendingModel.get(0)
var snapshot = service.snapshotFromRow(entry)
pendingModel.remove(0)
pastModel.insert(0, snapshot)
}
while (pastModel.count > service.historyCap) {
pastModel.remove(pastModel.count - 1)
}
scheduleHistorySave()
})
}
function dismissPopup(index) {
if (index < 0 || index >= popupModel.count) return
var entry = popupModel.get(index)
var ref = entry ? entry.ref : null
var originalId = entry ? entry.originalId : -1
popupModel.remove(index)
if (ref) {
try {
if (ref.tracked) ref.dismiss()
} catch (e) {
// Object already torn down by the server — nothing to dismiss.
}
}
// User (or the lifetime timer) saw the popup — archive it.
if (originalId >= 0) markSeenByOriginalId(originalId)
}
function clearPopups() {
while (popupModel.count > 0) dismissPopup(0)
}
function dismissPending(index) {
if (index < 0 || index >= pendingModel.count) return
var entry = pendingModel.get(index)
if (entry) maybeDeleteCachedImage(entry.image)
pendingModel.remove(index)
scheduleHistorySave()
}
function dismissPast(index) {
if (index < 0 || index >= pastModel.count) return
var entry = pastModel.get(index)
if (entry) maybeDeleteCachedImage(entry.image)
pastModel.remove(index)
scheduleHistorySave()
}
function clearPending() {
for (var i = 0; i < pendingModel.count; i++) {
var entry = pendingModel.get(i)
if (entry) maybeDeleteCachedImage(entry.image)
}
pendingModel.clear()
scheduleHistorySave()
}
function clearPast() {
for (var i = 0; i < pastModel.count; i++) {
var entry = pastModel.get(i)
if (entry) maybeDeleteCachedImage(entry.image)
}
pastModel.clear()
scheduleHistorySave()
}
// Invoke the libnotify "default" action on the popup's underlying
// notification, if it has one, then dismiss. Clients register the default
// action with the canonical identifier "default"; e.g. screenshot toasts
// use `notify-send -A default=Edit` so click-the-card opens the editor.
function invokePopupDefault(index) {
if (index < 0 || index >= popupModel.count) return
var entry = popupModel.get(index)
var ref = entry ? entry.ref : null
var invoked = false
if (ref && ref.actions) {
for (var i = 0; i < ref.actions.length; i++) {
var action = ref.actions[i]
if (action && action.identifier === "default") {
try { action.invoke(); invoked = true } catch (e) { console.warn("invoke default failed:", e) }
break
}
}
}
// Chat apps (Slack, Discord, Vesktop, etc.) rarely register a "default"
// libnotify action — they just expect clicking the notification to
// focus their window. Fall back to focusing the sending app by class so
// that click-to-jump actually works.
if (!invoked) focusApp(entry)
dismissPopup(index)
}
// Try to focus an existing Hyprland window matching the notification's
// sender. We shell out to a small bash one-liner because Hyprland's class
// matcher is regex-based but its case-sensitivity is implementation-
// defined (std::regex doesn't reliably honor `(?i)`). Easier to query the
// client list ourselves and pick the first match.
function focusApp(entry) {
if (!entry || !entry.app) return
var lower = String(entry.app).toLowerCase()
focusAppProc.command = ["bash", "-lc",
"hyprctl clients -j 2>/dev/null | " +
"jq -r --arg name \"" + lower + "\" " +
"'[.[] | select((.class // \"\") | ascii_downcase | startswith($name))] | first.address // empty' | " +
"xargs -r -I{} hyprctl dispatch focuswindow address:{}"]
focusAppProc.running = true
}
Process { id: focusAppProc; running: false }
// Open the popup's large image in the user's default image viewer.
// image:// URIs come from the upstream raw-bytes provider and have no
// on-disk path to hand to xdg-open, so we just dismiss in that case.
function openPopupImage(index) {
if (index < 0 || index >= popupModel.count) return
var entry = popupModel.get(index)
if (!entry) return
var image = String(entry.image || "")
if (image.indexOf("file://") === 0) {
var lower = image.toLowerCase()
if (lower.endsWith(".png") || lower.endsWith(".jpg") ||
lower.endsWith(".jpeg") || lower.endsWith(".webp")) {
var path = decodeURIComponent(image.substring(7))
Quickshell.execDetached(["xdg-open", path])
}
}
dismissPopup(index)
}
// ---------------------------------------------------- image cache
//
// Notifications coming from screenshot helpers ship an `image-path` hint
// pointing at /tmp/<file>. We want the history thumbnail to outlive that
// file, so we copy it into a long-lived cache dir on ingress and rewrite
// the history row's `image` to point at the cache once cp finishes.
// image:// (raw-bytes) URIs aren't trivially copyable from QML; document
// and skip them for v1.
function imageExtension(srcPath) {
var lower = srcPath.toLowerCase()
var dot = lower.lastIndexOf(".")
if (dot < 0) return "png"
var ext = lower.substring(dot + 1)
if (ext.length === 0 || ext.length > 5) return "png"
return ext
}
function maybeCacheImage(snapshot) {
var image = String(snapshot.image || "")
if (!image) return
// image:// URIs are decoded from raw bytes by Quickshell's image provider.
// We can't copy them out from QML, so let history reference them by URI
// and accept that they disappear with the source notification.
if (image.indexOf("image://") === 0) return
if (image.indexOf("file:///tmp/") !== 0) return
var srcPath = decodeURIComponent(image.substring(7))
var ext = imageExtension(srcPath)
var destPath = imageCacheDir + snapshot.timestamp + "-" + snapshot.originalId + "." + ext
var destUri = "file://" + destPath
imageCacheProc.targetUri = destUri
imageCacheProc.matchOriginalId = snapshot.originalId
imageCacheProc.matchTimestamp = snapshot.timestamp
imageCacheProc.command = ["cp", "-f", srcPath, destPath]
imageCacheProc.running = true
}
function maybeDeleteCachedImage(image) {
var path = String(image || "")
if (!path) return
if (path.indexOf("file://") !== 0) return
var local = decodeURIComponent(path.substring(7))
if (local.indexOf(imageCacheDir) !== 0) return
deleteImageProc.command = ["rm", "-f", local]
deleteImageProc.running = true
}
Process {
id: ensureDirsProc
command: ["mkdir", "-p", service.cacheDir, service.imageCacheDir]
running: false
}
Process {
id: imageCacheProc
property string targetUri: ""
property int matchOriginalId: -1
property double matchTimestamp: 0
onExited: function(exitCode) {
if (exitCode !== 0 || !targetUri) return
// Search both models since a notification may have moved to past
// between when we kicked off the copy and when it finished.
function rewrite(model) {
for (var i = 0; i < model.count; i++) {
var row = model.get(i)
if (row && row.originalId === matchOriginalId && row.timestamp === matchTimestamp) {
model.setProperty(i, "image", targetUri)
return true
}
}
return false
}
if (rewrite(pendingModel) || rewrite(pastModel)) scheduleHistorySave()
}
}
Process { id: deleteImageProc; running: false }
// ---------------------------------------------------- history persistence
FileView {
id: historyFile
path: service.historyPath
watchChanges: false
atomicWrites: true
printErrors: false
onLoaded: service.loadHistory(text())
// First-run: the file doesn't exist yet. Without this branch,
// `historyLoaded` stays false forever and `scheduleHistorySave` becomes
// a no-op — so the file is never created and history vanishes on
// shell restart.
onLoadFailed: service.loadHistory("")
}
Timer {
id: historySaveTimer
interval: 200
repeat: false
onTriggered: service.flushHistory()
}
// Past is a rolling "recently" window. Sweep every minute and drop
// anything older than 15 minutes so the tab doesn't accumulate forever.
readonly property int pastTtlMs: 15 * 60 * 1000
Timer {
id: pastPruneTimer
interval: 60 * 1000
repeat: true
running: true
triggeredOnStart: true
onTriggered: service.prunePast()
}
function prunePast() {
if (pastModel.count === 0) return
var cutoff = Date.now() - service.pastTtlMs
var removed = false
for (var i = pastModel.count - 1; i >= 0; i--) {
var entry = pastModel.get(i)
if (entry && entry.timestamp && entry.timestamp < cutoff) {
if (entry.image) maybeDeleteCachedImage(entry.image)
pastModel.remove(i)
removed = true
}
}
if (removed) scheduleHistorySave()
}
function scheduleHistorySave() {
if (!service.historyLoaded) return
historySaveTimer.restart()
}
property bool historyLoaded: false
function loadHistory(raw) {
var text = String(raw || "").trim()
if (!text) { service.historyLoaded = true; return }
try {
var parsed = JSON.parse(text)
if (parsed && typeof parsed.dnd === "boolean") {
service._hydrating = true
persisted.doNotDisturb = parsed.dnd
service._hydrating = false
}
var pending = (parsed && Array.isArray(parsed.pending)) ? parsed.pending : []
var past = (parsed && Array.isArray(parsed.past)) ? parsed.past : []
// v1 backwards compat: the old schema had a single `entries` array.
// Treat all of those as past since the user already presumably saw
// them (and DND-suppressed notifications from before the split are
// a rare edge case).
if (parsed && Array.isArray(parsed.entries)) past = past.concat(parsed.entries)
function entryFor(e) {
return {
id: e.id || 0,
originalId: e.originalId || e.id || 0,
app: e.app || "",
appIcon: e.appIcon || "",
summary: e.summary || "",
body: e.body || "",
image: e.image || "",
glyph: e.glyph || "",
urgency: typeof e.urgency === "number" ? e.urgency : NotificationUrgency.Normal,
timestamp: e.timestamp || 0,
ref: null
}
}
// Newest-first on disk; insert in order so models match.
Qt.callLater(function() {
for (var i = 0; i < pending.length; i++) {
if (!pending[i]) continue
pendingModel.append(entryFor(pending[i]))
if (pendingModel.count > service.historyCap) pendingModel.remove(pendingModel.count - 1)
}
for (var j = 0; j < past.length; j++) {
if (!past[j]) continue
pastModel.append(entryFor(past[j]))
if (pastModel.count > service.historyCap) pastModel.remove(pastModel.count - 1)
}
service.historyLoaded = true
})
} catch (e) {
console.warn("notifications: history parse failed:", e)
service.historyLoaded = true
}
}
function flushHistory() {
function dump(model) {
var out = []
for (var i = 0; i < model.count; i++) {
var r = model.get(i)
if (!r) continue
out.push({
id: r.id,
originalId: r.originalId,
app: r.app,
appIcon: r.appIcon,
summary: r.summary,
body: r.body,
image: r.image,
glyph: r.glyph || "",
urgency: r.urgency,
timestamp: r.timestamp
})
}
return out
}
var payload = {
version: 2,
dnd: persisted.doNotDisturb,
pending: dump(pendingModel),
past: dump(pastModel)
}
historyFile.setText(JSON.stringify(payload, null, 2) + "\n")
}
Component.onCompleted: {
ensureDirsProc.running = true
// Once mkdir has had a tick, load the existing history file. FileView
// surfaces an empty string when the file doesn't exist; loadHistory
// handles that path.
Qt.callLater(function() { historyFile.reload() })
}
// ---------------------------------------------------- IPC
IpcHandler {
target: "notifications"
function dndState(): string {
return service.doNotDisturb ? "on" : "off"
}
function toggleDnd(): string {
service.setDoNotDisturb(!service.doNotDisturb)
return dndState()
}
function setDnd(value: string): string {
var v = String(value || "").toLowerCase()
var on = v === "true" || v === "1" || v === "on" || v === "yes"
service.setDoNotDisturb(on)
return dndState()
}
function isDnd(): string {
return dndState()
}
function showHistory(): string {
service.historyOpenRequested()
return "ok"
}
// `clear` empties the past tab (the "I already saw these" bucket).
function clear(): string {
service.clearPast()
return "ok"
}
function clearPending(): string {
service.clearPending()
return "ok"
}
function markAllSeen(): string {
service.markAllSeen()
return "ok"
}
function dismissAll(): string {
service.clearPopups()
service.clearPending()
service.clearPast()
return "ok"
}
// dismiss the most recent popup; fall back to the most recent pending
// entry, then past, if no popup is currently showing.
function dismissOne(): string {
if (popupModel.count > 0) {
service.dismissPopup(0)
return "ok"
}
if (pendingModel.count > 0) {
service.dismissPending(0)
return "ok"
}
if (pastModel.count > 0) {
service.dismissPast(0)
return "ok"
}
return "none"
}
// Fire the default action on the most recent popup, then dismiss it.
function invokeLast(): string {
if (popupModel.count === 0) return "none"
service.invokePopupDefault(0)
return "ok"
}
function dismiss(summary: string): string {
var needle = String(summary || "")
if (!needle) return "none"
var hit = false
function sweep(model, dismissFn) {
for (var i = model.count - 1; i >= 0; i--) {
var row = model.get(i)
if (row && String(row.summary || "").indexOf(needle) !== -1) {
dismissFn(i)
hit = true
}
}
}
sweep(pendingModel, service.dismissPending)
sweep(pastModel, service.dismissPast)
sweep(popupModel, service.dismissPopup)
return hit ? "ok" : "none"
}
function ping(): string { return "ok" }
}
// ---------------------------------------------------- server
NotificationServer {
id: server
keepOnReload: false
imageSupported: true
actionsSupported: true
bodyMarkupSupported: true
bodyHyperlinksSupported: true
persistenceSupported: true
onNotification: function(notification) {
service.handleNotification(notification)
}
}
// -------------------------------------------------------------- popup UI
//
// One PanelWindow per output (Variants on Quickshell.screens) holding the
// stacked toast cards. Layer is Overlay, exclusionMode Ignore, no
// keyboard focus — popups are passive surfaces and must never steal input
// from the focused application.
Variants {
model: Quickshell.screens
PanelWindow {
id: popupWindow
required property var modelData
screen: modelData
visible: popupModel.count > 0
WlrLayershell.namespace: "omarchy-notifications"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
color: "transparent"
anchors {
top: service.barPosition !== "bottom"
bottom: service.barPosition === "bottom"
left: service.barPosition === "left"
right: service.barPosition !== "left"
}
margins {
top: service.barPosition === "top" ? service.barClearance + 12 : 20
bottom: service.barPosition === "bottom" ? service.barClearance + 12 : 20
left: service.barPosition === "left" ? service.barClearance + 12 : 20
right: service.barPosition === "right" ? service.barClearance + 12 : 20
}
implicitWidth: popupColumn.implicitWidth
implicitHeight: popupColumn.implicitHeight
ColumnLayout {
id: popupColumn
anchors.right: parent.right
anchors.top: parent.top
spacing: 8
Repeater {
model: popupModel
// The delegate is a slot Item that owns lifetime state (timer,
// progress, paused). The actual visuals live in NotificationCard,
// which the history panel also reuses.
delegate: Item {
id: cardSlot
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 string glyph
required property int urgency
required property double timestamp
// Each card sizes itself based on mode (text vs media); the slot
// tracks the card so the column auto-fits to whichever is widest.
Layout.preferredWidth: card.implicitWidth
Layout.alignment: Qt.AlignRight
implicitHeight: card.implicitHeight
readonly property real lifetime: service.durationFor(cardSlot.urgency)
property real progress: 1.0
readonly property bool ticking: cardSlot.lifetime > 0 && !card.hovered
Timer {
interval: 50
repeat: true
running: cardSlot.ticking
onTriggered: {
if (cardSlot.lifetime <= 0) return
cardSlot.progress -= 50.0 / cardSlot.lifetime
if (cardSlot.progress <= 0) {
cardSlot.progress = 0
service.dismissPopup(cardSlot.index)
}
}
}
NotificationCard {
id: card
anchors.right: parent.right
app: cardSlot.app
appIcon: cardSlot.appIcon
summary: cardSlot.summary
body: cardSlot.body
image: cardSlot.image
urgency: cardSlot.urgency
timestamp: cardSlot.timestamp
cornerRadius: service.cornerRadius
borderColorOverride: service.effectiveBorder
backgroundColorOverride: service.effectiveBackground
textColorOverride: service.effectiveText
countdownColorOverride: service.effectiveCountdown
fontFamily: service.shell && service.shell.bar ? service.shell.bar.fontFamily : ""
glyph: cardSlot.glyph
progress: cardSlot.progress
showProgress: cardSlot.lifetime > 0
onCloseRequested: service.dismissPopup(cardSlot.index)
onCardClicked: service.invokePopupDefault(cardSlot.index)
onImageClicked: service.openPopupImage(cardSlot.index)
}
}
}
}
}
}
}
@@ -0,0 +1,257 @@
// Notification card. Pure presentational — no service, Notification, or
// ListModel references. The popup container drives lifetime; the history
// panel drives static rendering. Both use the same component.
import QtQuick
import QtQuick.Layouts
import qs.Commons
Rectangle {
id: root
property string app: ""
property string appIcon: ""
property string summary: ""
property string body: ""
property string image: ""
// Nerd Font glyph rendered in the icon slot when no real icon is set.
// Used by omarchy-notification-send so user-action toasts (`Silenced
// notifications` etc.) show their bell/lock/etc. glyph without leaking
// into the summary text.
property string glyph: ""
// NotificationUrgency: Low=0, Normal=1, Critical=2 (upstream).
property int urgency: 1
property double timestamp: 0
property int cornerRadius: 10
property real progress: 1.0
property bool showProgress: false
// Container can override the theme defaults per-card. Defaults bind to
// the live Color.* tokens, so when the container leaves them alone the
// card still tracks the theme.
property color borderColorOverride: Color.border
property color backgroundColorOverride: Color.background
property color textColorOverride: Color.foreground
property color countdownColorOverride: Color.accent
// System font from shell.json bar.fontFamily, injected by the container.
property string fontFamily: ""
readonly property bool hovered: hoverTracker.hovered
signal closeRequested()
signal cardClicked()
signal imageClicked()
// Media mode = the notification carries a real screenshot or screen
// recording preview. Quickshell normalizes file paths from `-i` and the
// `image-path` hint into `image://icon//<absolute path>` (double slash
// marks an absolute filesystem path vs a themed icon name like
// `image://icon/firefox`).
function _imageFilePath(s) {
if (!s) return ""
if (s.indexOf("image://icon//") === 0) return s.substring("image://icon/".length)
if (s.indexOf("file://") === 0) return decodeURIComponent(s.substring(7))
return ""
}
function _isMediaFile(path) {
if (!path) return false
var lower = path.toLowerCase()
return lower.endsWith(".png") || lower.endsWith(".jpg") ||
lower.endsWith(".jpeg") || lower.endsWith(".webp") ||
lower.endsWith(".gif")
}
readonly property string mediaImageSource: {
if (_isMediaFile(_imageFilePath(image))) return image
if (_isMediaFile(_imageFilePath(appIcon))) return appIcon
return ""
}
readonly property bool mediaMode: mediaImageSource.length > 0
// Use only what the notification explicitly carries — no themed-icon
// theme-lookup fallback because Quickshell's icon image provider returns
// a placeholder for missing names (rather than erroring), which means
// we'd render Qt's pink "broken image" pattern for any unknown app.
// Apps that send their own icon via `image` (image-data hint) or
// `appIcon` (-i flag) still get one.
readonly property string smallIconSource: image.length > 0 ? image : appIcon
readonly property bool hasGlyph: glyph.length > 0
readonly property bool hasSmallIcon: !mediaMode && (smallIconSource.length > 0 || hasGlyph)
readonly property color dimColor: Qt.darker(textColorOverride, 1.4)
readonly property color bodyColor: Qt.darker(textColorOverride, 1.15)
readonly property color hoverColor: Qt.rgba(textColorOverride.r, textColorOverride.g, textColorOverride.b, 0.14)
readonly property color accentColor: urgency === 2 ? Color.urgent : (urgency === 0 ? dimColor : countdownColorOverride)
function sanitizeBody(s) {
return String(s).replace(/<img[^>]*>/gi, "")
}
implicitWidth: 380
// Add 2 * border.width so mainColumn (inset by border.width on top/left/right)
// doesn't push content under the bottom edge. The bottom edge is also inset
// for symmetry except when the progress bar replaces it.
implicitHeight: mainColumn.implicitHeight + border.width * 2 + (showProgress ? 3 : 0)
radius: cornerRadius
color: backgroundColorOverride
border.color: urgency === 2 ? Color.urgent : borderColorOverride
border.width: 2
clip: true
HoverHandler { id: hoverTracker }
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.cardClicked()
}
ColumnLayout {
id: mainColumn
// Inset by the card border so the hero image (and the text row) don't
// paint over the card's outer border. Without this the left/right/top
// border is invisible under the image.
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.topMargin: root.border.width
anchors.leftMargin: root.border.width
anchors.rightMargin: root.border.width
spacing: 0
// Hero image strip (media notifications only). PreserveAspectCrop so
// the preview looks like a clean banner without dark letterboxing.
Item {
Layout.fillWidth: true
Layout.preferredHeight: 140
visible: root.mediaMode
clip: true
Image {
anchors.fill: parent
source: root.mediaImageSource
fillMode: Image.PreserveAspectCrop
sourceSize.width: width > 0 ? width * Screen.devicePixelRatio : 0
sourceSize.height: height > 0 ? height * Screen.devicePixelRatio : 0
asynchronous: true
smooth: true
cache: false
}
// Bottom divider matching the card border so the screenshot is
// visually framed on every side (card border wraps top/left/right;
// this line completes the bottom).
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: root.border.width
color: root.urgency === 2 ? Color.urgent : root.borderColorOverride
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root.imageClicked()
}
}
// Text content. Always rendered — for media notifications this carries
// the summary/body ("Screenshot saved" etc) under the hero image.
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 12
Layout.rightMargin: 12
Layout.topMargin: 10
Layout.bottomMargin: 10
spacing: 10
Item {
id: smallIconSlot
Layout.preferredWidth: 32
Layout.preferredHeight: 32
Layout.alignment: Qt.AlignVCenter
// Hide the slot when the icon failed to resolve (themed-icon name
// not in the user's icon theme) AND we don't have a glyph fallback
// — prevents rendering Qt's pink broken-image placeholder.
visible: root.hasSmallIcon && (root.hasGlyph || smallIconImage.status !== Image.Error)
Image {
id: smallIconImage
anchors.fill: parent
source: root.smallIconSource
sourceSize.width: 32 * Screen.devicePixelRatio
sourceSize.height: 32 * Screen.devicePixelRatio
fillMode: Image.PreserveAspectFit
asynchronous: true
smooth: true
visible: !root.hasGlyph || smallIconImage.status === Image.Ready
}
// Glyph fallback (Nerd Font character) when no image icon is
// available. Used by omarchy-notification-send's `-g` flag.
Text {
anchors.centerIn: parent
visible: root.hasGlyph && smallIconImage.status !== Image.Ready
text: root.glyph
color: root.textColorOverride
font.family: root.fontFamily
font.pixelSize: 18
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
spacing: 2
Text {
Layout.fillWidth: true
visible: root.summary.length > 0
text: root.summary
font.family: root.fontFamily
color: root.textColorOverride
font.pixelSize: 13
font.bold: true
wrapMode: Text.WordWrap
elide: Text.ElideRight
maximumLineCount: 2
}
Text {
Layout.fillWidth: true
Layout.topMargin: 2
visible: root.body.length > 0
text: root.sanitizeBody(root.body)
textFormat: Text.StyledText
font.family: root.fontFamily
color: root.bodyColor
font.pixelSize: 12
wrapMode: Text.WordWrap
elide: Text.ElideRight
maximumLineCount: 3
}
}
}
}
// Progress bar at the bottom edge. Stays visible while the container has
// a finite lifetime; freezes (doesn't decrement) when hover pauses the
// tick from the container side.
Rectangle {
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
height: 3
color: root.borderColorOverride
visible: root.showProgress
Rectangle {
anchors.left: parent.left
anchors.top: parent.top
anchors.bottom: parent.bottom
width: parent.width * Math.max(0, Math.min(1, root.progress))
color: root.accentColor
}
}
}
@@ -0,0 +1,14 @@
{
"schemaVersion": 1,
"id": "omarchy.notifications",
"name": "Notifications",
"version": "1.0.0",
"author": "Omarchy",
"description": "Notification daemon, popups, and history",
"kinds": ["service"],
"activation": "persistent",
"keepLoaded": true,
"entryPoints": {
"service": "Service.qml"
}
}
@@ -385,7 +385,7 @@ Item {
"voxtype": { name: "Voxtype", description: "Voxtype dictation state", category: "Status" },
"screenRecording": { name: "Screen recording", description: "Active recording indicator", category: "Status" },
"idle": { name: "Idle (legacy)", description: "Inhibitor indicator", category: "Status" },
"notifications": { name: "DND (mako)", description: "Notification silencing indicator", category: "Status" },
"notifications": { name: "DND", description: "Do-not-disturb indicator", category: "Status" },
"tray": { name: "System tray", description: "Status notifier items", category: "Status" },
"bluetooth": { name: "Bluetooth (legacy)", description: "Bluetooth status icon", category: "Network" },
"network": { name: "Network (legacy)", description: "Wi-Fi / ethernet status", category: "Network" },
@@ -1594,8 +1594,8 @@ Item {
}
Process {
id: readDndProc
command: ["bash", "-lc", "makoctl mode 2>/dev/null | grep -q do-not-disturb && echo yes || echo no"]
stdout: SplitParser { onRead: function(line) { dndOn = String(line).trim() === "yes" } }
command: ["bash", "-lc", "omarchy-shell-ipc notifications isDnd 2>/dev/null || echo off"]
stdout: SplitParser { onRead: function(line) { dndOn = String(line).trim() === "on" } }
}
Process {
id: readIdleProc
@@ -1694,7 +1694,7 @@ Item {
StyleToggleRow {
label: "Notifications"
description: dndOn ? "Do-not-disturb is on — Mako is silencing." : "Notifications post normally via Mako."
description: dndOn ? "Do-not-disturb is on — notifications are silenced." : "Notifications post normally."
isOn: !dndOn
onToggle: runSystem("omarchy-toggle-notification-silencing")
}
@@ -158,6 +158,7 @@ ShellRoot {
// chains rescan() once the directory exists. We also kick a scan here in
// case the user dir already existed at startup.
pluginRegistry.rescan()
shell._syncFirstPartyServices()
// Wire Noctalia compat singletons. Plugins read state from these globals;
// we hand them references to the host so they can route into the right
@@ -178,6 +179,10 @@ ShellRoot {
persistShellConfig(copy)
}
// Exposed as a property so child plugins (notifications, future panels)
// can read barSize/barHidden/position to anchor relative to the bar.
property alias bar: bar
Bar {
id: bar
omarchyPath: shell.omarchyPath
@@ -351,6 +356,94 @@ ShellRoot {
function onPluginsChanged() { shell._syncNoctaliaServices() }
}
// ---------------------------------------------------- first-party services
//
// Generic loader for any first-party plugin that declares kind "service".
// The notification daemon is the first user; future first-party services
// (background updaters, idle inhibitor, etc.) plug in here. Mirrors
// ensureNoctaliaService but skips the noctalia-specific pluginApi handoff
// and gates on `__isFirstParty` instead of `__noctaliaCompat`.
Item {
id: firstPartyServiceHost
visible: false
}
property var _firstPartyServices: ({})
function firstPartyServiceFor(pluginId) {
return _firstPartyServices[String(pluginId)] || null
}
function ensureFirstPartyService(pluginId) {
var key = String(pluginId)
if (_firstPartyServices[key]) return _firstPartyServices[key]
var manifest = pluginRegistry && pluginRegistry.installedPlugins
? pluginRegistry.installedPlugins[key] : null
if (!manifest || !manifest.__isFirstParty) return null
if (manifest.__noctaliaCompat) return null
if (!Array.isArray(manifest.kinds) || manifest.kinds.indexOf("service") === -1) return null
if (!manifest.entryPoints || !manifest.entryPoints.service) return null
var url = pluginRegistry.entryPointUrl(manifest, "service")
if (!url) return null
var comp = Qt.createComponent(url, Component.PreferSynchronous)
function finalize() {
if (comp.status !== Component.Ready) {
console.warn("first-party service load failed for " + key + ": " + comp.errorString())
return
}
var inst = comp.createObject(firstPartyServiceHost, {
omarchyPath: shell.omarchyPath,
shell: shell,
manifest: manifest
})
if (!inst) {
console.warn("first-party service createObject returned null for", key)
return
}
var snext = ({})
for (var sk in _firstPartyServices) snext[sk] = _firstPartyServices[sk]
snext[key] = inst
_firstPartyServices = snext
}
if (comp.status === Component.Loading) {
comp.statusChanged.connect(finalize)
return null
}
finalize()
return _firstPartyServices[key] || null
}
function _syncFirstPartyServices() {
if (!pluginRegistry || !pluginRegistry.installedPlugins) return
var plugins = pluginRegistry.installedPlugins
for (var id in plugins) {
var m = plugins[id]
if (!m || !m.__isFirstParty || m.__noctaliaCompat) continue
if (!Array.isArray(m.kinds) || m.kinds.indexOf("service") === -1) continue
if (!m.entryPoints || !m.entryPoints.service) continue
if (!pluginRegistry.isEnabled(id)) continue
if (_firstPartyServices[id]) continue
ensureFirstPartyService(id)
}
// Drop services for plugins that have been disabled or removed.
for (var existingId in _firstPartyServices) {
var stillThere = plugins[existingId]
var stillEnabled = stillThere && pluginRegistry.isEnabled(existingId)
if (stillThere && stillEnabled) continue
var inst = _firstPartyServices[existingId]
if (inst && typeof inst.destroy === "function") inst.destroy()
var next = ({})
for (var k in _firstPartyServices) if (k !== existingId) next[k] = _firstPartyServices[k]
_firstPartyServices = next
}
}
Connections {
target: shell.pluginRegistry
function onPluginsChanged() { shell._syncFirstPartyServices() }
}
// Used by Noctalia compat: pluginApi.saveSettings() ends up writing inline
// settings to the plugin's entry in shell.json. moduleName is the entry id
// (the bare manifest id, e.g. "noctalia.air-quality"); settings is the
@@ -564,6 +657,10 @@ ShellRoot {
if ("manifest" in item) item.manifest = panelEntry.manifest
if ("barWidgetRegistry" in item) item.barWidgetRegistry = shell.barWidgetRegistry
if ("pluginRegistry" in item) item.pluginRegistry = shell.pluginRegistry
// First-party plugins that pair a panel UI with a service entry
// (e.g. omarchy.notifications) read shared state off `service`. We
// hand them the matching service instance if one was loaded.
if ("service" in item) item.service = shell.firstPartyServiceFor(panelEntry.pluginId)
// Noctalia panel/overlay/menu plugins expect a pluginApi just like
// their bar widgets do. First-party Omarchy plugins don't declare
// the property so the `in target` check skips them.
-6
View File
@@ -1,6 +0,0 @@
include=~/.local/share/omarchy/default/mako/core.ini
include=~/.local/state/omarchy/toggles/mako.ini
text-color={{ foreground }}
border-color={{ accent }}
background-color={{ background }}
@@ -1,6 +1,7 @@
#!/bin/bash
if makoctl mode | grep -q 'do-not-disturb'; then
state=$(omarchy-shell-ipc notifications isDnd 2>/dev/null || echo off)
if [[ $state == "on" ]]; then
echo '{"text": "󰂛", "tooltip": "Notifications silenced", "class": "active"}'
else
echo '{"text": ""}'
-3
View File
@@ -17,8 +17,5 @@ rm -rf ~/.config/chromium/SingletonLock # otherwise archiso will own the chromiu
mkdir -p ~/.config/btop/themes
ln -snf ~/.config/omarchy/current/theme/btop.theme ~/.config/btop/themes/current.theme
mkdir -p ~/.config/mako
ln -snf ~/.config/omarchy/current/theme/mako.ini ~/.config/mako/config
# Default Chromium to follow system appearance ("device") instead of dark
echo '{"browser":{"theme":{"color_scheme":0,"color_scheme2":0}}}' | sudo tee /usr/lib/chromium/initial_preferences >/dev/null
-1
View File
@@ -1,5 +1,4 @@
# Make sure toggles are available
mkdir -p ~/.local/state/omarchy/toggles
touch ~/.local/state/omarchy/toggles/hyprlock.conf
touch ~/.local/state/omarchy/toggles/mako.ini
touch ~/.local/state/omarchy/toggles/walker.css
+4 -1
View File
@@ -5,4 +5,7 @@ set -e
# Remove this hook after use
rm -f "$0"
notify-send " Install Dictation with Voxtype" "Click to install voice dictation for Omarchy." -u critical
(
action=$(notify-send " Install Dictation with Voxtype" "Click to install voice dictation for Omarchy." -u critical -A "default=Install")
[[ $action == "default" ]] && omarchy-launch-floating-terminal-with-presentation omarchy-voxtype-install
) >/dev/null 2>&1 &
+4 -1
View File
@@ -1 +1,4 @@
notify-send " Learn Keybindings" "Super + K for cheatsheet.\nSuper + Space for application launcher.\nSuper + Alt + Space for Omarchy Menu." -u critical
(
action=$(notify-send " Learn Keybindings" "Super + K for cheatsheet.\nSuper + Space for application launcher.\nSuper + Alt + Space for Omarchy Menu." -u critical -A "default=Open")
[[ $action == "default" ]] && omarchy-menu-keybindings
) >/dev/null 2>&1 &
+17 -3
View File
@@ -1,6 +1,20 @@
notify_update() {
(
action=$(notify-send " Update System" "$1" -u critical -A "default=Update")
[[ $action == "default" ]] && omarchy-launch-floating-terminal-with-presentation omarchy-update
) >/dev/null 2>&1 &
}
notify_wifi() {
(
action=$(notify-send "󰖩 Click to Setup Wi-Fi" "Tab to navigate, Space to select, ? for help." -u critical -A "default=Setup")
[[ $action == "default" ]] && omarchy-launch-wifi
) >/dev/null 2>&1 &
}
if ! ping -c3 -W1 1.1.1.1 >/dev/null 2>&1; then
notify-send " Update System" "When you have internet, click to update the system." -u critical
notify-send "󰖩 Click to Setup Wi-Fi" "Tab to navigate, Space to select, ? for help." -u critical
notify_update "When you have internet, click to update the system."
notify_wifi
else
notify-send " Update System" "Click to update the system." -u critical
notify_update "Click to update the system."
fi
-1
View File
@@ -77,7 +77,6 @@ libreoffice-fresh
llvm
localsend
luarocks
mako
man-db
mariadb-libs
mise
+27
View File
@@ -0,0 +1,27 @@
echo "Replace mako with the omarchy-shell notification plugin"
# Stop any running instance so the new daemon can take the bus name. The
# unit is Type=dbus / BusName=org.freedesktop.Notifications, so until mako
# releases the name our quickshell server can't register.
pkill -x mako 2>/dev/null || true
systemctl --user stop mako.service 2>/dev/null || true
# Uninstall the package outright. This also deletes
# /usr/lib/systemd/user/mako.service — D-Bus activation has nothing to
# look up once the unit file is gone, so notify-send can never respawn it.
# (`disable` isn't useful here: it only affects boot-time activation, not
# the on-demand D-Bus path that's how mako actually starts.)
if pacman -Qq mako >/dev/null 2>&1; then
sudo pacman -Rns --noconfirm mako >/dev/null 2>&1 || true
fi
# Old user config dir is no longer read.
[[ -d ~/.config/mako ]] && rm -rf ~/.config/mako
# Old toggle file is no longer read.
[[ -f ~/.local/state/omarchy/toggles/mako.ini ]] && rm -f ~/.local/state/omarchy/toggles/mako.ini
# Reload the shell so the new NotificationServer claims the bus name.
if command -v omarchy-restart-quickshell >/dev/null 2>&1; then
omarchy-restart-quickshell >/dev/null 2>&1 || true
fi