33 Commits

Author SHA1 Message Date
1201e6a4cc Feature: Sync dotfiles on system update
update-system.sh now runs sync-dotfiles.sh first, which pulls the repo
(fast-forward only) and re-stows it so both machines pick up config changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:34:07 +01:00
626c7dbcc1 Feature: Move volume OSD to bottom-center and enlarge slightly
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:28:55 +01:00
35374800bf Fix: Volume keys use wpctl instead of missing pamixer
Quickshell's VolumeOsd now shows changes, so drop notify-send popups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:28:55 +01:00
7cb7f10ff6 Fix: Only create Sunshine headless output on desktop (no battery)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:10:21 +01:00
88484f8495 Remove: Drop orbit, network click opens nm-connection-editor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:10:21 +01:00
1babf2e334 Feature: Shared pacman options installed via system/install.sh
Enables Color through an Include snippet in /etc/pacman.d so pacman
options stay in sync between machines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:10:21 +01:00
cc78daea77 Merge branch 'main' of ssh://gitea.thomasleewilliams.co.uk:2222/thomas/dotfiles 2026-10-11 16:52:33 +01:00
0415804b67 applying changes 2026-10-11 16:50:54 +01:00
75aa6b945b UI: Remove reduced opacity on unfocused windows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:46:06 +01:00
8b65136048 Feature: Add ForwardAgent for passing SSH Keys 2026-10-11 02:05:54 +01:00
4bd3d327c6 Feature: Seafile starts on login and is pinned in the tray
Launches seafile-applet from startup.lua and adds it to PinnedTray between Bitwarden and Sunshine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:49:04 +01:00
786a3d191a Fix: Typo in Hyprland startup.lua stopped startup apps launching
The typo caused a Lua error on login, so the rest of the startup handler never ran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:38:24 +01:00
ad5efc9597 Feature: cl alias now runs fastfetch after clear
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:53:56 +01:00
850da708c2 UI: Update icon no longer highlights when updates are available
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:53:56 +01:00
de39c40460 Feature: Added Videos bookmark
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:53:56 +01:00
72db6ca784 Fix: Disabled VRR to stop flickering on Acer monitor
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:53:56 +01:00
84828e58bc Feature: Use Bitwarden SSH agent for GUI apps
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:53:56 +01:00
26b7bf4ecc Feature: Enabled NVIDIA environment variables
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:53:56 +01:00
5b58c27e80 Fix: Removed Comments 2026-09-06 02:06:00 +01:00
82be653c04 UI: Corrected Sizing 2026-09-06 01:02:45 +01:00
d395a155af Feature: Added Weather widget 2026-09-06 01:00:25 +01:00
243c90a809 Features: Added Update functionality 2026-09-06 00:35:20 +01:00
38df0301f2 Feature: Icon does not appear unless installed 2026-09-05 23:10:26 +01:00
714fadc997 Fix: Corrected opened URL and Added assets for Sunshine 2026-09-05 23:03:02 +01:00
99e2225b51 UI: Made Bitwarden correct size 2026-09-05 22:58:20 +01:00
4aaa1d2d98 UI: Fixes Sizes 2026-09-05 22:16:10 +01:00
14b7e748d4 Feature: Add pinned tray for Bitwarden and Sunshine
Replaces the disabled generic system tray with a curated PinnedTray
showing just Bitwarden and Sunshine. Each shows its live tray icon
(and forwards clicks to it) while running; when not running it shows
a dimmed static icon and launches the app on click instead.

Right-clicking Sunshine specifically opens its web UI in the browser
rather than forwarding to secondaryActivate(), since that's how it's
actually configured/used.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 22:10:21 +01:00
a0b9ea29fe Feature: Added options for mullvad vpn 2026-09-05 21:56:42 +01:00
007843db6e Fix: Not to render on display with HEADLESS 2026-09-05 21:55:57 +01:00
764652d826 Feature: Added new window for steam big picture 2026-09-05 21:55:16 +01:00
8dedfe30fa Merge pull request 'Removal: Stopped Waybar from starting' (#7) from maintinance/remove_waybar into main
Reviewed-on: #7

granted
2026-08-27 15:02:44 +01:00
bc54a4b5b7 Removal: Stopped Waybar from starting 2026-08-27 15:01:45 +01:00
d306270dc8 Merge pull request 'features/adding_quickshell' (#6) from features/adding_quickshell into main
Reviewed-on: #6

tested on home laptop
tested in home PC

will need to make install scripts for process

approved
2026-08-27 14:55:29 +01:00
39 changed files with 2056 additions and 224 deletions

View File

@@ -46,6 +46,11 @@ stow -d ~/Projects/dotfiles/ -t ~/ dotfiles
This will sync wallpapers/config files to where they are usually located This will sync wallpapers/config files to where they are usually located
System-level config (e.g. pacman options) lives in `system/` and is installed with sudo:
```console
~/Projects/dotfiles/system/install.sh
```
## 📦 Programs ## 📦 Programs
List of programs and tools I use. List of programs and tools I use.

View File

@@ -0,0 +1 @@
SSH_AUTH_SOCK=${HOME}/.bitwarden-ssh-agent.sock

View File

@@ -3,5 +3,6 @@ file:///home/thomas/Documents
file:///home/thomas/Downloads file:///home/thomas/Downloads
file:///home/thomas/Pictures file:///home/thomas/Pictures
file:///home/thomas/Music file:///home/thomas/Music
file:///home/thomas/Videos
smb://192.168.1.84/media/ smb://192.168.1.84/media/
smb://192.168.1.85/music/ smb://192.168.1.85/music/

View File

@@ -51,10 +51,10 @@ env = ELECTRON_OZONE_PLATFORM_HINT,auto # auto selects Wayland if possible, X11
# This is from Hyprland Wiki. Below will be activated nvidia gpu detected # This is from Hyprland Wiki. Below will be activated nvidia gpu detected
# See hyprland wiki https://wiki.hyprland.org/Nvidia/#environment-variables # See hyprland wiki https://wiki.hyprland.org/Nvidia/#environment-variables
#env = LIBVA_DRIVER_NAME,nvidia env = LIBVA_DRIVER_NAME,nvidia
#env = __GLX_VENDOR_LIBRARY_NAME,nvidia env = __GLX_VENDOR_LIBRARY_NAME,nvidia
#env = NVD_BACKEND,direct env = NVD_BACKEND,direct
#env = GSK_RENDERER,ngl env = GSK_RENDERER,ngl
# additional ENV's for nvidia. Caution, activate with care # additional ENV's for nvidia. Caution, activate with care
#env = GBM_BACKEND,nvidia-drm #env = GBM_BACKEND,nvidia-drm
@@ -87,3 +87,6 @@ env = ELECTRON_OZONE_PLATFORM_HINT,auto # auto selects Wayland if possible, X11
# env = HYPRLAND_NO_RT,1 # Disables realtime priority setting by Hyprland. # env = HYPRLAND_NO_RT,1 # Disables realtime priority setting by Hyprland.
# env = HYPRLAND_NO_SD_NOTIFY,1 # If systemd, disables the 'sd_notify' calls. # env = HYPRLAND_NO_SD_NOTIFY,1 # If systemd, disables the 'sd_notify' calls.
# env = HYPRLAND_NO_SD_VARS,1 # Disables management of variables in systemd and dbus activation environments. # env = HYPRLAND_NO_SD_VARS,1 # Disables management of variables in systemd and dbus activation environments.
# Bitwarden SSH agent (so GUI apps like VS Code see it, not just shells)
env = SSH_AUTH_SOCK,/home/thomas/.bitwarden-ssh-agent.sock

View File

@@ -21,7 +21,7 @@ decoration {
rounding = 10 rounding = 10
active_opacity = 1.0 active_opacity = 1.0
inactive_opacity = 0.9 inactive_opacity = 1.0
fullscreen_opacity = 1.0 fullscreen_opacity = 1.0
dim_inactive = true dim_inactive = true

View File

@@ -8,5 +8,5 @@
# <<<<< TUI AUTO START # <<<<< TUI AUTO START
monitor=desc:LG Electronics LG ULTRAWIDE 0x0002E68A,2560x1080@60.00000,1920x-740,1.00000000,transform,3,vrr,0 monitor=desc:LG Electronics LG ULTRAWIDE 0x0002E68A,2560x1080@60.00000,1920x-740,1.00000000,transform,3,vrr,0
monitor=desc:HP Inc. HP 524pf VNG447017D,1920x1080@100.00000,-1920x0,1.00000000,transform,0,vrr,0 monitor=desc:HP Inc. HP 524pf VNG447017D,1920x1080@100.00000,-1920x0,1.00000000,transform,0,vrr,0
monitor=desc:Acer Technologies VG240Y S 0x126191DE,1920x1080@165.00301,0x0,1.00000000,transform,0,vrr,1,bitdepth,10,cm,wide monitor=desc:Acer Technologies VG240Y S 0x126191DE,1920x1080@165.00301,0x0,1.00000000,transform,0,vrr,0,bitdepth,10,cm,wide
# <<<<< TUI AUTO END # <<<<< TUI AUTO END

View File

@@ -24,7 +24,7 @@ hl.config({
rounding = 10, rounding = 10,
active_opacity = 1.0, active_opacity = 1.0,
inactive_opacity = 0.9, inactive_opacity = 1.0,
fullscreen_opacity = 1.0, fullscreen_opacity = 1.0,
dim_inactive = true, dim_inactive = true,

View File

@@ -49,10 +49,10 @@ hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto") -- auto selects Wayland if possib
-- This is from the Hyprland wiki. Uncomment below if an nvidia GPU is detected. -- This is from the Hyprland wiki. Uncomment below if an nvidia GPU is detected.
-- See hyprland wiki https://wiki.hyprland.org/Nvidia/#environment-variables -- See hyprland wiki https://wiki.hyprland.org/Nvidia/#environment-variables
-- hl.env("LIBVA_DRIVER_NAME", "nvidia") hl.env("LIBVA_DRIVER_NAME", "nvidia")
-- hl.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia") hl.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
-- hl.env("NVD_BACKEND", "direct") hl.env("NVD_BACKEND", "direct")
-- hl.env("GSK_RENDERER", "ngl") hl.env("GSK_RENDERER", "ngl")
-- additional ENV's for nvidia. Caution, activate with care -- additional ENV's for nvidia. Caution, activate with care
-- hl.env("GBM_BACKEND", "nvidia-drm") -- hl.env("GBM_BACKEND", "nvidia-drm")
@@ -85,3 +85,6 @@ hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto") -- auto selects Wayland if possib
-- hl.env("HYPRLAND_NO_RT", "1") -- Disables realtime priority setting by Hyprland. -- hl.env("HYPRLAND_NO_RT", "1") -- Disables realtime priority setting by Hyprland.
-- hl.env("HYPRLAND_NO_SD_NOTIFY", "1") -- If systemd, disables the 'sd_notify' calls. -- hl.env("HYPRLAND_NO_SD_NOTIFY", "1") -- If systemd, disables the 'sd_notify' calls.
-- hl.env("HYPRLAND_NO_SD_VARS", "1") -- Disables management of variables in systemd and dbus activation environments. -- hl.env("HYPRLAND_NO_SD_VARS", "1") -- Disables management of variables in systemd and dbus activation environments.
-- Bitwarden SSH agent (so GUI apps like VS Code see it, not just shells)
hl.env("SSH_AUTH_SOCK", os.getenv("HOME") .. "/.bitwarden-ssh-agent.sock")

View File

@@ -5,6 +5,11 @@
-- whatever it last wrote. If you reinstall HyprMod, prefer letting its own "Migrate to Lua…" -- whatever it last wrote. If you reinstall HyprMod, prefer letting its own "Migrate to Lua…"
-- wizard take over managing this file instead of hand-editing it going forward. -- wizard take over managing this file instead of hand-editing it going forward.
-- vrr forced off globally: the per-monitor vrr = 0 flag below does NOT override this when
-- misc.vrr is 1 (tested live on Hyprland 0.56.2 — DP-2 stayed vrr:true until this global
-- was changed). DP-2 (Acer VG240Y S) flickers under VRR when idle in fullscreen (mouse
-- stationary -> no repaint damage -> frame delivery stalls near the panel's VRR floor).
-- The other two monitors don't negotiate VRR at all, so this has no effect on them.
hl.config({ hl.config({
general = { general = {
allow_tearing = true, allow_tearing = true,
@@ -13,12 +18,12 @@ hl.config({
}, },
}, },
misc = { misc = {
vrr = 1, vrr = 0,
}, },
}) })
-- Monitors -- Monitors
hl.monitor({ output = "DP-2", mode = "1920x1080@165.00Hz", position = "0x0", scale = 1, bitdepth = 10, cm = "wide" }) hl.monitor({ output = "DP-2", mode = "1920x1080@165.00Hz", position = "0x0", scale = 1, bitdepth = 10, cm = "wide", vrr = 0 })
-- Keybinds -- Keybinds
-- (unbind = SUPER SHIFT, Print / unbind = SUPER, up from the source .conf have no Lua equivalent needed — -- (unbind = SUPER SHIFT, Print / unbind = SUPER, up from the source .conf have no Lua equivalent needed —

View File

@@ -14,6 +14,9 @@ hl.on("hyprland.start", function()
hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP") hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP")
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP") hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP")
-- Sunshine (virtual monitor only on desktop; skipped on laptops, detected by a system battery)
hl.exec_cmd("sleep 1 && ! ls -d /sys/class/power_supply/BAT* >/dev/null 2>&1 && hyprctl output create headless")
-- Polkit (Polkit Gnome / KDE) -- Polkit (Polkit Gnome / KDE)
hl.exec_cmd(scriptsDir .. "/Polkit.sh") hl.exec_cmd(scriptsDir .. "/Polkit.sh")
@@ -23,7 +26,7 @@ hl.on("hyprland.start", function()
-- hl.exec_cmd("ags") -- hl.exec_cmd("ags")
-- hl.exec_cmd("blueman-applet") -- hl.exec_cmd("blueman-applet")
-- hl.exec_cmd("rog-control-center") -- hl.exec_cmd("rog-control-center")
hl.exec_cmd("waybar") -- hl.exec_cmd("waybar")
hl.exec_cmd("qs") -- quickshell AGS Desktop Overview alternative hl.exec_cmd("qs") -- quickshell AGS Desktop Overview alternative
-- clipboard manager -- clipboard manager
@@ -31,7 +34,10 @@ hl.on("hyprland.start", function()
hl.exec_cmd("wl-paste --type image --watch cliphist store") hl.exec_cmd("wl-paste --type image --watch cliphist store")
-- Starting hypridle to start hyprlock -- Starting hypridle to start hyprlock
hl.exec_cmd("hypridle") -- hl.exec_cmd("hypridle")
-- Sunshine for streaming media
hl.exec_cmd("sunshine")
-- Float + center the Bitwarden extension pop-out (window rules can't catch it — see the script) -- Float + center the Bitwarden extension pop-out (window rules can't catch it — see the script)
hl.exec_cmd(scriptsDir .. "/FloatBitwardenPopup.sh") hl.exec_cmd(scriptsDir .. "/FloatBitwardenPopup.sh")
@@ -43,6 +49,7 @@ hl.on("hyprland.start", function()
hl.exec_cmd("sleep 3 && vesktop") hl.exec_cmd("sleep 3 && vesktop")
hl.dispatch(hl.dsp.focus({ workspace = 1 })) hl.dispatch(hl.dsp.focus({ workspace = 1 }))
hl.exec_cmd("bitwarden-desktop --ozone-platform-hint=auto --silent") hl.exec_cmd("bitwarden-desktop --ozone-platform-hint=auto --silent")
hl.exec_cmd("seafile-applet")
-- Here are a list of features available but disabled by default -- Here are a list of features available but disabled by default
-- hl.exec_cmd("awww-daemon --format xrgb && awww img " .. os.getenv("HOME") .. "/Pictures/wallpapers/mecha-nostalgia.png") -- persistent wallpaper -- hl.exec_cmd("awww-daemon --format xrgb && awww img " .. os.getenv("HOME") .. "/Pictures/wallpapers/mecha-nostalgia.png") -- persistent wallpaper

View File

@@ -2,10 +2,15 @@
general:allow_tearing = 1 general:allow_tearing = 1
general:snap:enabled = 0 general:snap:enabled = 0
misc:vrr = 1 # vrr forced off globally: the per-monitor vrr,0 flag below does NOT override this when
# misc:vrr is 1 (tested live on Hyprland 0.56.2 — DP-2 stayed vrr:true until this global
# was changed). DP-2 (Acer VG240Y S) flickers under VRR when idle in fullscreen (mouse
# stationary -> no repaint damage -> frame delivery stalls near the panel's VRR floor).
# The other two monitors don't negotiate VRR at all, so this has no effect on them.
misc:vrr = 0
# Monitors # Monitors
monitor = DP-2, 1920x1080@165.00Hz, 0x0, 1, bitdepth, 10, cm, wide monitor = DP-2, 1920x1080@165.00Hz, 0x0, 1, bitdepth, 10, cm, wide, vrr, 0
# Keybinds # Keybinds
unbind = SUPER SHIFT, Print unbind = SUPER SHIFT, Print

View File

@@ -10,5 +10,6 @@
-- <<<<< TUI AUTO START -- <<<<< TUI AUTO START
hl.monitor({ output = "desc:LG Electronics LG ULTRAWIDE 0x0002E68A", mode = "2560x1080@60.00000", position = "1920x-740", scale = 1.00000000, transform = 3, vrr = 0 }) hl.monitor({ output = "desc:LG Electronics LG ULTRAWIDE 0x0002E68A", mode = "2560x1080@60.00000", position = "1920x-740", scale = 1.00000000, transform = 3, vrr = 0 })
hl.monitor({ output = "desc:HP Inc. HP 524pf VNG447017D", mode = "1920x1080@100.00000", position = "-1920x0", scale = 1.00000000, transform = 0, vrr = 0 }) hl.monitor({ output = "desc:HP Inc. HP 524pf VNG447017D", mode = "1920x1080@100.00000", position = "-1920x0", scale = 1.00000000, transform = 0, vrr = 0 })
hl.monitor({ output = "desc:Acer Technologies VG240Y S 0x126191DE", mode = "1920x1080@165.00301", position = "0x0", scale = 1.00000000, transform = 0, vrr = 1, bitdepth = 10, cm = "wide" }) hl.monitor({ output = "desc:Acer Technologies VG240Y S 0x126191DE", mode = "1920x1080@165.00301", position = "0x0", scale = 1.00000000, transform = 0, vrr = 0, bitdepth = 10, cm = "wide" })
hl.monitor({ output = "HEADLESS-1", mode = "1728x1080@60.00000", position = "4480x0", scale = 1.00000000, transform = 0, vrr = 0 })
-- <<<<< TUI AUTO END -- <<<<< TUI AUTO END

View File

@@ -137,8 +137,8 @@ hl.window_rule({ workspace = "3", match = { class = "^([Vv]esktop|[Dd]iscord|[Ss
hl.window_rule({ size = "20% 20%", match = { class = "^([Pp]lexamp)" } }) hl.window_rule({ size = "20% 20%", match = { class = "^([Pp]lexamp)" } })
-- FLOAT -- FLOAT
hl.window_rule({ name = "windowrule-75", float = true, opacity = "0.9 0.7", size = "monitor_w*0.7 monitor_h*0.7", match = { tag = "wallpaper*" } }) hl.window_rule({ name = "windowrule-75", float = true, opacity = "0.9 0.9", size = "monitor_w*0.7 monitor_h*0.7", match = { tag = "wallpaper*" } })
hl.window_rule({ name = "windowrule-76", float = true, opacity = "0.8 0.7", size = "monitor_w*0.7 monitor_h*0.7", match = { tag = "settings*" } }) hl.window_rule({ name = "windowrule-76", float = true, opacity = "0.8 0.8", size = "monitor_w*0.7 monitor_h*0.7", match = { tag = "settings*" } })
hl.window_rule({ name = "windowrule-77", float = true, match = { tag = "viewer*" } }) hl.window_rule({ name = "windowrule-77", float = true, match = { tag = "viewer*" } })
hl.window_rule({ name = "windowrule-78", float = true, match = { class = "([Zz]oom|onedriver|onedriver-launcher)$" } }) hl.window_rule({ name = "windowrule-78", float = true, match = { class = "([Zz]oom|onedriver|onedriver-launcher)$" } })
hl.window_rule({ name = "windowrule-79", float = true, match = { class = "(org.gnome.Calculator)", title = "(Calculator)" } }) hl.window_rule({ name = "windowrule-79", float = true, match = { class = "(org.gnome.Calculator)", title = "(Calculator)" } })
@@ -162,17 +162,17 @@ hl.window_rule({ name = "windowrule-92", float = true, center = true, size = "mo
-- END of float popups and dialogue ####### -- END of float popups and dialogue #######
-- OPACITY -- OPACITY
-- hl.window_rule({ opacity = "0.9 0.7", match = { tag = "browser*" } }) -- hl.window_rule({ opacity = "0.9 0.9", match = { tag = "browser*" } })
-- hl.window_rule({ opacity = "0.9 0.8", match = { tag = "projects*" } }) -- hl.window_rule({ opacity = "0.9 0.9", match = { tag = "projects*" } })
-- hl.window_rule({ opacity = "0.94 0.86", match = { tag = "im*" } }) -- hl.window_rule({ opacity = "0.94 0.94", match = { tag = "im*" } })
-- hl.window_rule({ opacity = "0.94 0.86", match = { tag = "multimedia*" } }) -- hl.window_rule({ opacity = "0.94 0.94", match = { tag = "multimedia*" } })
-- hl.window_rule({ opacity = "0.9 0.8", match = { tag = "file-manager*" } }) -- hl.window_rule({ opacity = "0.9 0.9", match = { tag = "file-manager*" } })
hl.window_rule({ name = "windowrule-93", opacity = "0.8 0.7", match = { tag = "terminal*" } }) hl.window_rule({ name = "windowrule-93", opacity = "0.8 0.8", match = { tag = "terminal*" } })
-- hl.window_rule({ opacity = "0.82 0.75", match = { tag = "viewer*" } }) -- hl.window_rule({ opacity = "0.82 0.82", match = { tag = "viewer*" } })
hl.window_rule({ name = "windowrule-94", opacity = "0.8 0.7", match = { class = "^(gedit|org.gnome.TextEditor|mousepad)$" } }) hl.window_rule({ name = "windowrule-94", opacity = "0.8 0.8", match = { class = "^(gedit|org.gnome.TextEditor|mousepad)$" } })
hl.window_rule({ name = "windowrule-95", opacity = "0.9 0.8", match = { class = "^(deluge)$" } }) hl.window_rule({ name = "windowrule-95", opacity = "0.9 0.9", match = { class = "^(deluge)$" } })
hl.window_rule({ name = "windowrule-96", opacity = "0.9 0.8", match = { class = "^(seahorse)$" } }) -- gnome-keyring gui hl.window_rule({ name = "windowrule-96", opacity = "0.9 0.9", match = { class = "^(seahorse)$" } }) -- gnome-keyring gui
-- hl.window_rule({ opacity = "0.95 0.75", match = { title = "^(Picture-in-Picture)$" } }) -- hl.window_rule({ opacity = "0.95 0.95", match = { title = "^(Picture-in-Picture)$" } })
-- SIZE -- SIZE
-- hl.window_rule({ size = "25% 25%", match = { title = "^(Firefox)$" } }) -- hl.window_rule({ size = "25% 25%", match = { title = "^(Firefox)$" } })

View File

@@ -13,6 +13,7 @@
hl.workspace_rule({ workspace = 1, monitor = "DP-2", default = true }) hl.workspace_rule({ workspace = 1, monitor = "DP-2", default = true })
hl.workspace_rule({ workspace = 2, monitor = "DP-1", default = true }) hl.workspace_rule({ workspace = 2, monitor = "DP-1", default = true })
hl.workspace_rule({ workspace = 3, monitor = "HDMI-A-1", default = true }) hl.workspace_rule({ workspace = 3, monitor = "HDMI-A-1", default = true })
hl.workspace_rule({ workspace = 9, monitor = "HEADLESS-1", default = true })
-- hl.workspace_rule({ workspace = 4, monitor = "eDP-1" }) -- hl.workspace_rule({ workspace = 4, monitor = "eDP-1" })
-- hl.workspace_rule({ workspace = 5, monitor = "DP-2" }) -- hl.workspace_rule({ workspace = 5, monitor = "DP-2" })
-- hl.workspace_rule({ workspace = 6, monitor = "DP-2" }) -- hl.workspace_rule({ workspace = 6, monitor = "DP-2" })

View File

@@ -1,143 +1,37 @@
#!/bin/bash #!/bin/bash
# HyprFlux — https://github.com/ahmad9059/HyprFlux # Volume controls for audio and mic via wpctl (PipeWire).
# Scripts for volume controls for audio and mic # Quickshell's VolumeOsd watches PipeWire directly, so no notifications here.
iDIR="$HOME/.config/swaync/icons" SINK="@DEFAULT_AUDIO_SINK@"
sDIR="$HOME/.config/hypr/scripts" SOURCE="@DEFAULT_AUDIO_SOURCE@"
STEP="5%"
# Get Volume # Print volume as an integer percent, or "Muted"
get_volume() { get_volume() {
volume=$(pamixer --get-volume) local out
if [[ "$volume" -eq "0" ]]; then out=$(wpctl get-volume "$1")
if [[ "$out" == *MUTED* ]]; then
echo "Muted" echo "Muted"
else else
echo "$volume %" awk '{ printf "%d %%\n", $2 * 100 + 0.5 }' <<< "$out"
fi fi
} }
# Get icons is_muted() {
get_icon() { [[ "$(wpctl get-volume "$1")" == *MUTED* ]]
current=$(get_volume)
if [[ "$current" == "Muted" ]]; then
echo "$iDIR/volume-mute.png"
elif [[ "${current%\%}" -le 30 ]]; then
echo "$iDIR/volume-low.png"
elif [[ "${current%\%}" -le 60 ]]; then
echo "$iDIR/volume-mid.png"
else
echo "$iDIR/volume-high.png"
fi
} }
# Notify # Raising/lowering while muted unmutes first, matching the old behaviour
notify_user() { inc() { is_muted "$1" && wpctl set-mute "$1" 0 || wpctl set-volume -l 1.0 "$1" "$STEP+"; }
if [[ "$(get_volume)" == "Muted" ]]; then dec() { is_muted "$1" && wpctl set-mute "$1" 0 || wpctl set-volume "$1" "$STEP-"; }
notify-send -e -h string:x-canonical-private-synchronous:volume_notif -h boolean:SWAYNC_BYPASS_DND:true -u low -i "$(get_icon)" " Volume:" " Muted"
else
notify-send -e -h int:value:"$(get_volume | sed 's/%//')" -h string:x-canonical-private-synchronous:volume_notif -h boolean:SWAYNC_BYPASS_DND:true -u low -i "$(get_icon)" " Volume Level:" " $(get_volume)" &&
"$sDIR/Sounds.sh" --volume
fi
}
# Increase Volume case "$1" in
inc_volume() { --inc) inc "$SINK" ;;
if [ "$(pamixer --get-mute)" == "true" ]; then --dec) dec "$SINK" ;;
toggle_mute --toggle) wpctl set-mute "$SINK" toggle ;;
else --toggle-mic) wpctl set-mute "$SOURCE" toggle ;;
pamixer -i 5 --allow-boost --set-limit 150 && notify_user --mic-inc) inc "$SOURCE" ;;
fi --mic-dec) dec "$SOURCE" ;;
} --get-mic) get_volume "$SOURCE" ;;
*) get_volume "$SINK" ;;
# Decrease Volume esac
dec_volume() {
if [ "$(pamixer --get-mute)" == "true" ]; then
toggle_mute
else
pamixer -d 5 && notify_user
fi
}
# Toggle Mute
toggle_mute() {
if [ "$(pamixer --get-mute)" == "false" ]; then
pamixer -m && notify-send -e -u low -h boolean:SWAYNC_BYPASS_DND:true -i "$iDIR/volume-mute.png" " Mute"
elif [ "$(pamixer --get-mute)" == "true" ]; then
pamixer -u && notify-send -e -u low -h boolean:SWAYNC_BYPASS_DND:true -i "$(get_icon)" " Volume:" " Switched ON"
fi
}
# Toggle Mic
toggle_mic() {
if [ "$(pamixer --default-source --get-mute)" == "false" ]; then
pamixer --default-source -m && notify-send -e -u low -h boolean:SWAYNC_BYPASS_DND:true -i "$iDIR/microphone-mute.png" " Microphone:" " Switched OFF"
elif [ "$(pamixer --default-source --get-mute)" == "true" ]; then
pamixer -u --default-source u && notify-send -e -u low -h boolean:SWAYNC_BYPASS_DND:true -i "$iDIR/microphone.png" " Microphone:" " Switched ON"
fi
}
# Get Mic Icon
get_mic_icon() {
current=$(pamixer --default-source --get-volume)
if [[ "$current" -eq "0" ]]; then
echo "$iDIR/microphone-mute.png"
else
echo "$iDIR/microphone.png"
fi
}
# Get Microphone Volume
get_mic_volume() {
volume=$(pamixer --default-source --get-volume)
if [[ "$volume" -eq "0" ]]; then
echo "Muted"
else
echo "$volume %"
fi
}
# Notify for Microphone
notify_mic_user() {
volume=$(get_mic_volume)
icon=$(get_mic_icon)
notify-send -e -h int:value:"$volume" -h "string:x-canonical-private-synchronous:volume_notif" -h boolean:SWAYNC_BYPASS_DND:true -u low -i "$icon" " Mic Level:" " $volume"
}
# Increase MIC Volume
inc_mic_volume() {
if [ "$(pamixer --default-source --get-mute)" == "true" ]; then
toggle_mic
else
pamixer --default-source -i 5 && notify_mic_user
fi
}
# Decrease MIC Volume
dec_mic_volume() {
if [ "$(pamixer --default-source --get-mute)" == "true" ]; then
toggle-mic
else
pamixer --default-source -d 5 && notify_mic_user
fi
}
# Execute accordingly
if [[ "$1" == "--get" ]]; then
get_volume
elif [[ "$1" == "--inc" ]]; then
inc_volume
elif [[ "$1" == "--dec" ]]; then
dec_volume
elif [[ "$1" == "--toggle" ]]; then
toggle_mute
elif [[ "$1" == "--toggle-mic" ]]; then
toggle_mic
elif [[ "$1" == "--get-icon" ]]; then
get_icon
elif [[ "$1" == "--get-mic-icon" ]]; then
get_mic_icon
elif [[ "$1" == "--mic-inc" ]]; then
inc_mic_volume
elif [[ "$1" == "--mic-dec" ]]; then
dec_mic_volume
else
get_volume
fi

View File

@@ -1,5 +0,0 @@
accent_primary = "#007acc"
accent_secondary = "#06b6d4"
background = "#1e1e1e"
foreground = "#d4d4d8"
opacity = 0.91

Binary file not shown.

After

Width:  |  Height:  |  Size: 294 KiB

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Sunshine_Logo" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
y="0px" width="256px" height="256px" viewBox="10 6 236 245" style="enable-background:new 0 0 256 256;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FDD107;}
.st1{fill:#F89A1C;}
.st2{fill:#EF3E23;}
.st3{fill:#F26222;}
</style>
<path class="st0" d="M118.7688675,20.7120476c0,0-63.8333359,26-74.3333359,83.8333282s37.1666718,91.5,86.3333282,75.3333282
s70.3333435-51,81.8333435-86.9999924c0,0-9.3333435,100.4999924-96.1666718,115.4999924s-118.1666641-50-82.1666641-119.8333282
C44.2688675,67.0453796,80.5188675,29.6287155,118.7688675,20.7120476z"/>
<path class="st1" d="M118.7688675,20.7120476c0,0-41.125,3.6666679-83.25,61.0416679s-28.125,139.125,34.25,149.375
s115.8749924-44.875,133.5-82.375s15.1666718-61.4583282,9.75-77.8749924c0,0,0.6666718,36.4166641-13.3333282,59.6666641
s-29.75,46.3333282-65.0833282,62.1666718s-74.1666718,13.75-95.4166718-19.25s-5.9166641-76.0833359-0.2916641-85.3333359
S72.3938675,33.7953796,118.7688675,20.7120476z"/>
<path class="st2" d="M73.0188675,39.6287155c0,0,38.125-28.125,76.8749924-28.125s63,28.25,68.5,52.25s6,54.125-11.5,87.6249924
s-37.375,56-79.1249924,76.125s-84.625,2.75-84.625,2.75s25.9769745,25.8750153,71.0509872,16.5
c45.0740051-9.375,82.2406769-40.875,98.4073486-69.5s28.7916565-57.3749924,27.6666565-92.2499924s-23.75-54.5-31.25-60.25
s-23.1875-17.8125-58.1875-16.5625S86.4563675,29.8162155,73.0188675,39.6287155z"/>
<path class="st3" d="M73.0188675,39.6287155c0,0,35-32.8125,82.4374924-32.8125s69.1875,24.8125,78.875,44.6875
s21.8125,70-12.1875,122.9999924s-74.625,67.375-93.625,71.625s-42.4311447,4.269165-59.1114044-1.2299957
c0,0,35.1947479,8.3966675,66.7780762-7.4366608s51.6666718-32.1666718,74.0833282-68.8333435
s25.9166718-72.7499924,22.1666718-93.9166565s-12.1666718-42.4166718-36.5-56.3333359s-56.7291718-10.531251-74.4791641-4.531251
S91.9876175,26.4099655,73.0188675,39.6287155z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1,72 @@
pragma Singleton
import QtQuick
import Quickshell.Io
// Shared state for SystemUpdate.qml, the bar's package-update indicator.
// Bar.qml is instantiated once per screen (see shell.qml's Variants), so
// without this being a singleton, plugging in extra monitors would mean
// running `checkupdates` that many times in parallel on every poll — which
// isn't just wasteful, it's actively unreliable: concurrent invocations
// race over checkupdates' own sync directory and can intermittently report
// 0 pending updates. One shared Process here means exactly one `checkupdates`
// run at a time, and every bar's icon reflects the same result.
QtObject {
id: root
// checkupdates syncs a throwaway copy of the pacman db, so it's not
// free to run often — matches the old waybar custom/updater module's
// ~21.7 minute poll interval (see .config/waybar/config).
readonly property int refreshIntervalMs: 1300 * 1000
property int updateCount: 0
property bool checking: false
property bool updating: false
function refresh() {
if (_checkProc.running) return
root.checking = true
_checkProc.running = true
}
// scriptPath: absolute path to update-system.sh (resolved by the caller
// via Quickshell.shellPath, which isn't available as a plain import
// here without pulling in the whole Quickshell singleton for one call).
function runUpdate(scriptPath) {
if (_updateProc.running) return
root.updating = true
_updateProc.command = [scriptPath]
_updateProc.running = true
}
// QtObject has no default property, so these are attached via property
// assignment rather than as plain child elements (same as Colors.qml's
// FileView).
readonly property Process _checkProc: Process {
command: ["sh", "-c", "checkupdates 2>/dev/null | wc -l"]
stdout: StdioCollector {
id: checkCollector
onStreamFinished: {
root.updateCount = parseInt(checkCollector.text.trim(), 10) || 0
root.checking = false
}
}
}
// Runs update-system.sh, which opens its own terminal and blocks until
// that terminal is closed — so this only exits once the user's actually
// done updating, which is exactly when a re-check matters.
readonly property Process _updateProc: Process {
onExited: {
root.updating = false
root.refresh()
}
}
readonly property Timer _timer: Timer {
interval: root.refreshIntervalMs
running: true
repeat: true
triggeredOnStart: true
onTriggered: root.refresh()
}
}

View File

@@ -0,0 +1,242 @@
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
// Live weather for the bar's Weather.qml icon. A singleton for the same
// reason as SystemUpdates.qml: Bar.qml is instantiated once per monitor
// (shell.qml's Variants), so without this being shared, every extra screen
// would mean another set of network requests running in parallel.
//
// Location is auto-detected from the public IP (via ipwho.is, no API key)
// and cached to disk (~/.cache/quickshell/weather-location.json) so it's
// only ever looked up once, not on every quickshell restart — IP-geolocation
// free tiers rate-limit fast, and a desktop's location barely ever changes
// anyway. Only the weather itself re-polls on a timer. Conditions come from
// Open-Meteo (api.open-meteo.com) — also free and keyless, and it does its
// own °C/km-h conversion server-side via query params.
//
// Loosely modeled on omarchy's weather panel
// (github.com/omacom/omarchy, quattro branch,
// shell/plugins/panels/weather/Model.js) for the WMO weather-code
// grouping, but the icons are this bar's own Material Symbols glyphs
// rather than pulling in a separate icon font just for this.
QtObject {
id: root
// Location barely ever changes for a desktop machine, so it's fetched
// once (and cached); the weather itself is worth re-checking
// periodically.
readonly property int refreshIntervalMs: 15 * 60 * 1000
property bool locating: true
property bool loading: false
property bool hasLocation: false
property string locationName: ""
property real latitude: 0
property real longitude: 0
function saveLocation() {
_locationCache.setText(JSON.stringify({
latitude: root.latitude,
longitude: root.longitude,
name: root.locationName
}))
}
// Called from Weather.qml's search box once you pick a result — always
// wins over whatever IP-based guess was cached before, and persists so
// it sticks across restarts.
function selectLocation(name, lat, lon) {
root.latitude = lat
root.longitude = lon
root.locationName = name
root.hasLocation = true
root.locating = false
root.saveLocation()
root.refresh()
}
property bool searching: false
property var searchResults: []
function search(query) {
const q = String(query || "").trim()
if (q.length < 2) {
root.searchResults = []
return
}
root.searching = true
_searchProc.command = ["curl", "-s", "--max-time", "6",
"https://geocoding-api.open-meteo.com/v1/search?count=5&language=en&format=json&name=" + encodeURIComponent(q)]
_searchProc.running = true
}
property real tempC: 0
property real feelsLikeC: 0
property int humidity: 0
property real windKmh: 0
property int weatherCode: 0
property bool isDay: true
// [{date, code, maxC, minC}] — tomorrow onward; today is already
// covered by the current-conditions fields above.
property var forecast: []
function locate() {
if (_locateProc.running) return
root.locating = true
_locateProc.running = true
}
function refresh() {
if (!root.hasLocation || _weatherProc.running) return
root.loading = true
_weatherProc.command = ["curl", "-s", "--max-time", "8",
"https://api.open-meteo.com/v1/forecast?latitude=" + root.latitude
+ "&longitude=" + root.longitude
+ "&current=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,is_day,wind_speed_10m"
+ "&daily=weather_code,temperature_2m_max,temperature_2m_min"
+ "&temperature_unit=celsius&wind_speed_unit=kmh&timezone=auto&forecast_days=4"]
_weatherProc.running = true
}
// Material Symbols Rounded glyph for a WMO weather code (see
// https://open-meteo.com/en/docs#weathervariables), grouped the same
// way omarchy's Model.js groups them into its own icon set.
function iconFor(code, day) {
if (code === 0) return day ? "sunny" : "clear_night"
if (code === 1 || code === 2) return day ? "partly_cloudy_day" : "partly_cloudy_night"
if (code === 3) return "cloud"
if (code === 45 || code === 48) return "foggy"
if ([51, 53, 55, 56, 57, 61, 63, 65, 66, 67, 80, 81, 82].indexOf(code) !== -1) return "rainy"
if ([71, 73, 75, 77, 85, 86].indexOf(code) !== -1) return "weather_snowy"
if (code === 95 || code === 96 || code === 99) return "thunderstorm"
return "cloud"
}
// QtObject has no default property, so these are attached via property
// assignment rather than as plain child elements (same as Colors.qml's
// FileView and SystemUpdates.qml's Process/Timer).
// Read once at startup; onLoaded/onLoadFailed (below) decide whether a
// network lookup is still needed.
readonly property FileView _locationCache: FileView {
path: Quickshell.env("HOME") + "/.cache/quickshell/weather-location.json"
onLoaded: {
try {
const data = JSON.parse(text())
const lat = parseFloat(data.latitude)
const lon = parseFloat(data.longitude)
if (!isNaN(lat) && !isNaN(lon)) {
root.latitude = lat
root.longitude = lon
root.locationName = data.name || ""
root.hasLocation = true
root.locating = false
root.refresh()
return
}
} catch (e) {
// fall through to a fresh lookup below
}
root.locate()
}
onLoadFailed: root.locate()
}
readonly property Process _locateProc: Process {
command: ["curl", "-s", "--max-time", "6", "https://ipwho.is/"]
stdout: StdioCollector {
id: locateCollector
onStreamFinished: {
try {
const data = JSON.parse(locateCollector.text)
const lat = parseFloat(data.latitude)
const lon = parseFloat(data.longitude)
root.hasLocation = data.success !== false && !isNaN(lat) && !isNaN(lon)
if (root.hasLocation) {
root.latitude = lat
root.longitude = lon
root.locationName = data.city || data.region || data.country || ""
root.saveLocation()
}
} catch (e) {
console.warn("Weather: locate parse failed:", e)
root.hasLocation = false
}
root.locating = false
if (root.hasLocation) root.refresh()
}
}
}
readonly property Process _weatherProc: Process {
stdout: StdioCollector {
id: weatherCollector
onStreamFinished: {
try {
const data = JSON.parse(weatherCollector.text)
const current = data.current || {}
root.tempC = current.temperature_2m
root.feelsLikeC = current.apparent_temperature
root.humidity = current.relative_humidity_2m
root.windKmh = current.wind_speed_10m
root.weatherCode = current.weather_code
root.isDay = current.is_day === 1
const daily = data.daily || {}
const times = daily.time || []
const days = []
// Start at 1 — index 0 is today, already shown as the
// current conditions above; this is "what's coming".
for (let i = 1; i < times.length && days.length < 3; i++) {
days.push({
date: times[i],
code: (daily.weather_code || [])[i],
maxC: (daily.temperature_2m_max || [])[i],
minC: (daily.temperature_2m_min || [])[i]
})
}
root.forecast = days
} catch (e) {
console.warn("Weather: failed to parse Open-Meteo response:", e)
}
root.loading = false
}
}
}
readonly property Process _searchProc: Process {
stdout: StdioCollector {
id: searchCollector
onStreamFinished: {
try {
const data = JSON.parse(searchCollector.text)
const results = data.results || []
root.searchResults = results.filter(function(r) {
return r && r.name && r.latitude !== undefined && r.longitude !== undefined
}).map(function(r) {
return {
name: r.name,
description: [r.admin1, r.country].filter(function(p) { return !!p }).join(", "),
latitude: r.latitude,
longitude: r.longitude
}
})
} catch (e) {
root.searchResults = []
}
root.searching = false
}
}
}
readonly property Timer _timer: Timer {
interval: root.refreshIntervalMs
running: true
repeat: true
onTriggered: root.refresh()
}
}

View File

@@ -2,3 +2,5 @@ module config
singleton Colors 1.0 Colors.qml singleton Colors 1.0 Colors.qml
singleton Appearance 1.0 Appearance.qml singleton Appearance 1.0 Appearance.qml
singleton VpnExclusivity 1.0 VpnExclusivity.qml singleton VpnExclusivity 1.0 VpnExclusivity.qml
singleton SystemUpdates 1.0 SystemUpdates.qml
singleton WeatherData 1.0 WeatherData.qml

View File

@@ -35,8 +35,12 @@ PanelWindow {
ActiveWindow {} ActiveWindow {}
} }
Clock { RowLayout {
anchors.centerIn: parent anchors.centerIn: parent
spacing: Appearance.spacing
Weather { barWindow: bar }
Clock {}
SystemUpdate { barWindow: bar }
} }
RowLayout { RowLayout {
@@ -46,7 +50,7 @@ PanelWindow {
rightMargin: Appearance.spacing rightMargin: Appearance.spacing
} }
spacing: Appearance.spacing spacing: Appearance.spacing
//Tray {} PinnedTray {}
TailscaleIndicator { barWindow: bar } TailscaleIndicator { barWindow: bar }
VpnIndicator { barWindow: bar } VpnIndicator { barWindow: bar }
NetworkIndicator { barWindow: bar } NetworkIndicator { barWindow: bar }

View File

@@ -222,6 +222,82 @@ Item {
root.passwordSsid = "" root.passwordSsid = ""
} }
// --- Captive portal ---------------------------------------------------
// NetworkManager tells "portal" (a walled-garden sign-in page sits
// between us and the real internet) apart from "full" connectivity only
// via an active probe — nmcli's cached CONNECTIVITY field just reflects
// NM's own internal check, which runs every few minutes and is far too
// slow to catch a portal right after joining a network. So this forces a
// fresh probe itself, same shell-out approach as the stats below, and
// runs it whenever we're connected — not gated to panel.visible like
// those — since the whole point is noticing this before the popup is
// ever opened.
//
// "limited" is treated the same as "portal", not just "full" vs.
// everything-else: NM only reports "portal" when the probe request
// actually comes back *different* (redirected/rewritten) from what it
// expected. Plenty of real captive portals instead black-hole every
// request that isn't to their own login domain, so the probe just times
// out — which is also why this is slow to resolve — and NM reports
// "limited" (it got a route/gateway, but the internet check itself
// never landed) rather than "portal". From the user's side both look
// identical: connected, but no real internet.
property string connectivityState: "unknown"
readonly property bool connectivityRestricted: root.connectivityState === "portal" || root.connectivityState === "limited"
// A plain, unencrypted HTTP page whose only job is to be interceptable
// by a captive portal's redirect. An https:// URL mostly just gets
// silently blocked instead of redirected, which looks identical to the
// portal not existing at all.
readonly property string captivePortalCheckUrl: "http://neverssl.com/"
// Auto-opened once per network join (mirrors TailscaleIndicator's
// openAuthUrl/_authUrlOpened) so flapping connectivity on the same
// network doesn't keep popping a new browser tab — the manual "Open"
// button in the panel banner below still works regardless.
property bool _portalAutoOpened: false
property bool _wasConnected: false
onConnectedDeviceChanged: {
const isConnected = root.connectedDevice !== null
if (isConnected && !root._wasConnected) {
root._portalAutoOpened = false
root.connectivityState = "unknown"
}
root._wasConnected = isConnected
}
onConnectivityRestrictedChanged: {
if (root.connectivityRestricted && !root._portalAutoOpened)
root.openCaptivePortal()
}
function openCaptivePortal() {
root._portalAutoOpened = true
Qt.openUrlExternally(root.captivePortalCheckUrl)
}
Process {
id: connectivityProc
command: ["nmcli", "networking", "connectivity", "check"]
stdout: StdioCollector {
id: connectivityCollector
onStreamFinished: root.connectivityState = connectivityCollector.text.trim()
}
}
Timer {
id: connectivityTimer
// Poll quickly while things are still unresolved (fresh join, or an
// active portal we're waiting to clear) and back off once
// connectivity is confirmed full, rather than shelling out every
// few seconds forever for no reason.
interval: root.connectivityState === "full" ? 20000 : 3000
repeat: true
triggeredOnStart: true
running: root.connectedDevice !== null
onTriggered: if (!connectivityProc.running) connectivityProc.running = true
}
// --- Live stats -------------------------------------------------- // --- Live stats --------------------------------------------------
// None of this comes from Quickshell's Networking API — it only knows // None of this comes from Quickshell's Networking API — it only knows
// about devices/networks, not throughput, ping, or routing. So this // about devices/networks, not throughput, ping, or routing. So this
@@ -595,6 +671,34 @@ Item {
} }
} }
// Small warning badge over the icon's corner when the connection looks
// restricted (a captive portal, confirmed or just suspected — see
// connectivityRestricted above) — same treatment as TailscaleIndicator's
// login-needed badge, so "this needs your attention" reads the same way
// across indicators.
Rectangle {
visible: root.connectivityRestricted
width: Math.max(7, label.font.pixelSize * 0.42)
height: width
radius: width / 2
color: Colors.urgent
border.color: Colors.surface
border.width: 1
anchors.right: label.right
anchors.bottom: label.bottom
anchors.rightMargin: -1
anchors.bottomMargin: -1
Text {
anchors.centerIn: parent
text: "!"
color: Colors.surface
font.family: Appearance.fontFamily
font.pixelSize: Math.max(6, parent.height * 0.75)
font.bold: true
}
}
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
@@ -890,6 +994,88 @@ Item {
} }
// Captive portal banner — shown when the connectivity probe
// above finds either a confirmed sign-in page or a
// "limited" result standing between this network and the
// real internet (see connectivityRestricted). openCaptivePortal()
// already fired once automatically when this was first
// detected; this stays up as a manual retry in case that tab
// got closed.
Rectangle {
width: parent.width
visible: root.connectivityRestricted
radius: 0
color: Qt.rgba(Colors.urgent.r, Colors.urgent.g, Colors.urgent.b, 0.18)
border.color: Colors.urgent
border.width: 1
implicitHeight: portalBannerRow.implicitHeight + 12
RowLayout {
id: portalBannerRow
anchors.fill: parent
anchors.margins: 6
spacing: 8
Text {
text: "lock_open"
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 16
color: Colors.urgent
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
Text {
Layout.fillWidth: true
text: root.connectivityState === "portal" ? "Sign-in required" : "No internet access"
elide: Text.ElideRight
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSize
font.bold: true
color: Colors.foreground
}
Text {
Layout.fillWidth: true
text: root.connectivityState === "portal"
? "This network needs a web sign-in first"
: "This network may need a web sign-in first"
elide: Text.ElideRight
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
}
Rectangle {
implicitWidth: openPortalText.implicitWidth + 16
implicitHeight: 24
radius: 0
color: openPortalMouse.containsMouse ? Colors.urgent : "transparent"
border.color: Colors.urgent
border.width: 1
Text {
id: openPortalText
anchors.centerIn: parent
text: "Open"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: openPortalMouse.containsMouse ? Colors.primaryText : Colors.urgent
}
MouseArea {
id: openPortalMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.openCaptivePortal()
}
}
}
}
// Live stats — ping/loss/throughput/IP/gateway for whichever // Live stats — ping/loss/throughput/IP/gateway for whichever
// device is currently connected. // device is currently connected.
GridLayout { GridLayout {

View File

@@ -0,0 +1,110 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Io
import Quickshell.Services.SystemTray
import Quickshell.Widgets
import "../../config"
RowLayout {
id: root
spacing: Appearance.spacing * 1.
readonly property var pinned: [
{
name: "Bitwarden",
icon: Qt.resolvedUrl("../../assets/bitwarden.png"),
binary: "bitwarden",
launch: ["bitwarden"],
secondary: ["pkill", "bitwarden"]
},
{
name: "Seafile",
icon: "seafile",
binary: "seafile-applet",
launch: ["seafile-applet"],
secondary: ["pkill", "seafile-applet"]
},
{
name: "Sunshine",
icon: Qt.resolvedUrl("../../assets/sunshine.svg"),
binary: "sunshine",
launch: ["systemctl", "--user", "start", "app-dev.lizardbyte.app.Sunshine.service"],
secondary: ["xdg-open", "https://xol.twhomelab.local:47990"],
},
]
function findTrayItem(name) {
const needle = name.toLowerCase()
for (const item of SystemTray.items.values) {
if (item.id.toLowerCase().includes(needle) || item.title.toLowerCase().includes(needle))
return item
}
return null
}
Repeater {
model: root.pinned
delegate: Item {
id: slot
required property var modelData
readonly property var trayItem: root.findTrayItem(modelData.name)
readonly property bool running: trayItem !== null
property bool installed: false // Application installation check
visible: slot.installed
implicitWidth: Appearance.iconSize
implicitHeight: Appearance.iconSize
Process {
running: true
command: ["which", slot.modelData.binary]
onExited: (exitCode) => slot.installed = exitCode === 0
}
IconImage {
anchors.fill: parent
// modelData.icon is a QUrl when it comes from
// Qt.resolvedUrl() (Bitwarden/Sunshine's local assets), not
// a QString — coerce to string before using String methods
// on it, or this throws instead of ever matching.
source: slot.running
? slot.trayItem.icon
: (String(slot.modelData.icon).includes("://")
? slot.modelData.icon
: Quickshell.iconPath(slot.modelData.icon))
opacity: slot.running ? 1.0 : 0.5
}
Process { id: launcher }
Process { id: secondaryProc }
MouseArea {
anchors.fill: parent
anchors.margins: -4
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton) {
if (slot.modelData.secondary) {
if (!secondaryProc.running) {
secondaryProc.command = slot.modelData.secondary
secondaryProc.running = true
}
} else if (slot.running) {
slot.trayItem.secondaryActivate()
}
} else if (slot.running) {
slot.trayItem.activate()
} else if (!launcher.running) {
launcher.command = slot.modelData.launch
launcher.running = true
}
}
}
}
}
}

View File

@@ -0,0 +1,194 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Hyprland
import "../../config"
Item {
id: root
implicitWidth: row.implicitWidth + 6
implicitHeight: Appearance.barHeight
required property var barWindow
RowLayout {
id: row
anchors.centerIn: parent
spacing: 4
Text {
id: label
font.family: SystemUpdates.checking ? Appearance.iconFontFamilyRounded : Appearance.fontFamily
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: SystemUpdates.checking ? 15 : 22
color: Colors.foreground
text: SystemUpdates.checking ? "progress_activity" : "󰏖"
RotationAnimation on rotation {
running: SystemUpdates.checking
loops: Animation.Infinite
from: 0
to: 360
duration: 900
onRunningChanged: if (!running) label.rotation = 0
}
}
Text {
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: label.color
text: SystemUpdates.updateCount
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton) {
SystemUpdates.refresh()
} else {
panel.visible = !panel.visible
grab.active = panel.visible
}
}
}
PopupWindow {
id: panel
visible: false
color: "transparent"
readonly property int contentWidth: 200
readonly property int contentPadding: 16
implicitWidth: contentWidth + contentPadding * 2
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
anchor {
window: root.barWindow
rect.x: 0
rect.y: 0
rect.width: root.barWindow.width
rect.height: root.barWindow.height
edges: Edges.Bottom
gravity: Edges.Bottom
margins.bottom: -4
}
HyprlandFocusGrab {
id: grab
windows: [panel]
onCleared: panel.visible = false
}
Rectangle {
anchors.fill: parent
radius: 0
color: Colors.surface
border.color: Colors.muted
border.width: 3
Column {
id: mainColumn
anchors.fill: parent
anchors.margins: panel.contentPadding
spacing: 10
RowLayout {
width: parent.width
spacing: 8
Text {
text: label.text
font.family: Appearance.fontFamily
font.pixelSize: 23
color: Colors.foreground
}
Text {
Layout.fillWidth: true
text: "System Update"
font.family: Appearance.fontFamily
font.pixelSize: 18
font.bold: true
color: Colors.foreground
}
}
Text {
width: parent.width
wrapMode: Text.WordWrap
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSize
color: Colors.foreground
text: {
if (SystemUpdates.updating) return "Update running in a terminal…"
if (SystemUpdates.checking) return "Checking for updates…"
if (SystemUpdates.updateCount === 0) return "System is up to date."
return SystemUpdates.updateCount + (SystemUpdates.updateCount === 1 ? " update available." : " updates available.")
}
}
Rectangle {
id: updateBtn
width: parent.width
radius: 0
implicitHeight: btnContent.implicitHeight + 12
enabled: !SystemUpdates.updating
opacity: SystemUpdates.updating ? 0.5 : 1
color: btnMouse.containsMouse ? Colors.primary : "transparent"
Rectangle {
anchors.fill: parent
anchors.margins: -3
radius: 0
color: "transparent"
border.color: Colors.foreground
border.width: btnMouse.containsMouse ? 1 : 0
}
RowLayout {
id: btnContent
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 6
spacing: 8
Text {
Layout.preferredWidth: 20
horizontalAlignment: Text.AlignLeft
text: "download"
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 14
color: btnMouse.containsMouse ? Colors.primaryText : Colors.foreground
}
Text {
Layout.fillWidth: true
text: "Update Now"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSize
color: btnMouse.containsMouse ? Colors.primaryText : Colors.foreground
}
}
MouseArea {
id: btnMouse
anchors.fill: parent
enabled: !SystemUpdates.updating
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
SystemUpdates.runUpdate(Quickshell.shellPath("scripts/update-system.sh"))
panel.visible = false
grab.active = false
}
}
}
}
}
}
}

View File

@@ -5,7 +5,7 @@ import Quickshell.Widgets
import "../../config" import "../../config"
RowLayout { RowLayout {
spacing: Appearance.spacing * 0.6 spacing: Appearance.spacing
Repeater { Repeater {
model: SystemTray.items model: SystemTray.items

View File

@@ -45,9 +45,41 @@ Item {
// to connect once the current one has finished disconnecting. // to connect once the current one has finished disconnecting.
property string pendingConnect: "" property string pendingConnect: ""
// Surfaced under the header for a failed connect/disconnect; cleared // Surfaced under each provider's own row for a failed connect/
// automatically after a few seconds. // disconnect/settings change; cleared automatically after a few
property string actionStatus: "" // seconds. Kept separate per provider so an error from one doesn't
// show up hanging under the other's row.
property string mullvadActionStatus: ""
property string protonActionStatus: ""
// --- Mullvad advanced options (auto-connect, relay location, multihop) --
// ProtonVPN's CLI is plain-text-only and far less predictable across
// versions, so this extra panel is Mullvad-specific rather than a
// second ProviderRow feature.
property bool mullvadOptionsOpen: false
property bool mullvadAutoConnect: false
property bool mullvadMultihop: false
// Human-readable current relay constraint, e.g. "Any" or "Sweden" —
// resolved from `relay get`'s "country xx[ city yyy]" syntax against
// mullvadCountries once that list has loaded.
property string mullvadLocationLabel: "Any"
property string mullvadEntryLabel: "Any"
// [{name, code}], parsed from `mullvad relay list` and cached for the
// life of the panel — the full relay list rarely changes and re-parsing
// ~50 countries on every open would just be wasted work.
property var mullvadCountries: []
// "" | "exit" | "entry" — which location field's picker is open, if any.
property string mullvadPickerTarget: ""
property string mullvadPickerFilter: ""
readonly property var mullvadFilteredCountries: {
const f = root.mullvadPickerFilter.trim().toLowerCase()
if (!f) return root.mullvadCountries
return root.mullvadCountries.filter(function(c) {
return c.name.toLowerCase().indexOf(f) !== -1 || c.code.indexOf(f) !== -1
})
}
function shortLine(text) { function shortLine(text) {
const line = String(text || "").trim().split("\n")[0] || "" const line = String(text || "").trim().split("\n")[0] || ""
@@ -82,6 +114,95 @@ Item {
VpnExclusivity.protonConnected = root.protonState === "connected" VpnExclusivity.protonConnected = root.protonState === "connected"
} }
function applyMullvadAutoConnect(text) {
const m = String(text || "").match(/autoconnect:\s*(on|off)/i)
root.mullvadAutoConnect = !!m && m[1].toLowerCase() === "on"
}
// Resolves relay get's "any" / "country xx" / "country xx city yyy" /
// bare-hostname syntax into a display label. Falls back to the raw
// country code (or the raw string) when mullvadCountries hasn't loaded
// yet, rather than showing nothing.
function describeMullvadLocation(raw) {
const s = String(raw || "").trim()
if (!s || s === "any") return "Any"
const m = s.match(/^country\s+([a-z]{2})\b/i)
if (!m) return s
const code = m[1].toLowerCase()
const country = root.mullvadCountries.find(function(c) { return c.code === code })
return country ? country.name : code.toUpperCase()
}
function applyMullvadRelay(text) {
const body = String(text || "")
const locMatch = body.match(/^\s*Location:\s*(.+)$/mi)
const multihopMatch = body.match(/^\s*Multihop state:\s*(\w+)/mi)
const entryMatch = body.match(/^\s*Multihop entry:\s*(.+)$/mi)
root.mullvadMultihop = !!multihopMatch && multihopMatch[1].toLowerCase() === "enabled"
root.mullvadLocationLabel = root.describeMullvadLocation(locMatch ? locMatch[1].trim() : "")
root.mullvadEntryLabel = root.describeMullvadLocation(entryMatch ? entryMatch[1].trim() : "")
}
// Only country-level lines are of interest ("Sweden (se)", flush left) —
// city ("\tGothenburg (got) @ ...") and server lines are indented and
// their parenthesised code is never exactly two letters, so the regex
// alone is enough to tell them apart without checking indentation.
function applyMullvadCountries(text) {
const lines = String(text || "").split("\n")
const countries = []
for (const line of lines) {
const m = line.match(/^(\S.*) \(([a-z]{2})\)\s*$/)
if (m) countries.push({ name: m[1].trim(), code: m[2] })
}
countries.sort(function(a, b) { return a.name.localeCompare(b.name) })
root.mullvadCountries = countries
// Re-resolve any labels already showing raw codes now that names
// are available.
if (!mullvadRelayGetProc.running) mullvadRelayGetProc.running = true
}
function openMullvadOptions() {
root.mullvadOptionsOpen = !root.mullvadOptionsOpen
if (root.mullvadOptionsOpen) {
if (!mullvadAutoConnectGetProc.running) mullvadAutoConnectGetProc.running = true
if (!mullvadRelayGetProc.running) mullvadRelayGetProc.running = true
if (root.mullvadCountries.length === 0 && !mullvadRelayListProc.running) mullvadRelayListProc.running = true
} else {
root.mullvadPickerTarget = ""
}
}
function openMullvadPicker(target) {
root.mullvadPickerFilter = ""
root.mullvadPickerTarget = root.mullvadPickerTarget === target ? "" : target
}
function pickMullvadLocation(code) {
const target = root.mullvadPickerTarget
root.mullvadPickerTarget = ""
root.setMullvadLocation(target, code)
}
function setMullvadAutoConnect(on) {
if (mullvadAutoConnectSetProc.running) return
mullvadAutoConnectSetProc.command = ["mullvad", "auto-connect", "set", on ? "on" : "off"]
mullvadAutoConnectSetProc.running = true
}
function setMullvadMultihop(on) {
if (mullvadRelaySetProc.running) return
mullvadRelaySetProc.command = ["mullvad", "relay", "set", "multihop", on ? "on" : "off"]
mullvadRelaySetProc.running = true
}
function setMullvadLocation(target, code) {
if (mullvadRelaySetProc.running) return
mullvadRelaySetProc.command = target === "entry"
? ["mullvad", "relay", "set", "entry", "location", code]
: ["mullvad", "relay", "set", "location", code]
mullvadRelaySetProc.running = true
}
function refreshMullvad() { function refreshMullvad() {
if (!root.mullvadInstalled) { if (!root.mullvadInstalled) {
if (!mullvadWhichProc.running) mullvadWhichProc.running = true if (!mullvadWhichProc.running) mullvadWhichProc.running = true
@@ -197,8 +318,8 @@ Item {
stderr: StdioCollector { id: mullvadConnectErr } stderr: StdioCollector { id: mullvadConnectErr }
onExited: (exitCode, exitStatus) => { onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) { if (exitCode !== 0) {
root.actionStatus = "Mullvad: " + root.shortLine(mullvadConnectErr.text || "connect failed") root.mullvadActionStatus = root.shortLine(mullvadConnectErr.text || "Connect failed")
actionStatusTimer.restart() mullvadActionStatusTimer.restart()
} else if (!tailscaleDownProc.running) { } else if (!tailscaleDownProc.running) {
tailscaleDownProc.running = true tailscaleDownProc.running = true
} }
@@ -213,8 +334,8 @@ Item {
stderr: StdioCollector { id: mullvadDisconnectErr } stderr: StdioCollector { id: mullvadDisconnectErr }
onExited: (exitCode, exitStatus) => { onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) { if (exitCode !== 0) {
root.actionStatus = "Mullvad: " + root.shortLine(mullvadDisconnectErr.text || "disconnect failed") root.mullvadActionStatus = root.shortLine(mullvadDisconnectErr.text || "Disconnect failed")
actionStatusTimer.restart() mullvadActionStatusTimer.restart()
} }
root.refreshMullvad() root.refreshMullvad()
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() } if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() }
@@ -229,6 +350,77 @@ Item {
onTriggered: if (mullvadStatusProc.running) mullvadStatusProc.running = false onTriggered: if (mullvadStatusProc.running) mullvadStatusProc.running = false
} }
// --- Mullvad advanced options: auto-connect, relay location, multihop --
// Only queried while the options section is open (see
// root.openMullvadOptions), not on the regular status poll — these
// don't change on their own the way tunnel state does.
Process {
id: mullvadAutoConnectGetProc
command: ["mullvad", "auto-connect", "get"]
stdout: StdioCollector {
id: mullvadAutoConnectGetCollector
onStreamFinished: root.applyMullvadAutoConnect(mullvadAutoConnectGetCollector.text)
}
}
Process {
id: mullvadAutoConnectSetProc
stdout: StdioCollector {}
stderr: StdioCollector { id: mullvadAutoConnectSetErr }
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
root.mullvadActionStatus = root.shortLine(mullvadAutoConnectSetErr.text || "Auto-connect change failed")
mullvadActionStatusTimer.restart()
}
if (!mullvadAutoConnectGetProc.running) mullvadAutoConnectGetProc.running = true
}
}
Process {
id: mullvadRelayGetProc
command: ["mullvad", "relay", "get"]
stdout: StdioCollector {
id: mullvadRelayGetCollector
onStreamFinished: root.applyMullvadRelay(mullvadRelayGetCollector.text)
}
}
Process {
id: mullvadRelayListProc
command: ["mullvad", "relay", "list"]
stdout: StdioCollector {
id: mullvadRelayListCollector
onStreamFinished: root.applyMullvadCountries(mullvadRelayListCollector.text)
}
}
Process {
id: mullvadRelaySetProc
stdout: StdioCollector {}
stderr: StdioCollector { id: mullvadRelaySetErr }
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
root.mullvadActionStatus = root.shortLine(mullvadRelaySetErr.text || "Relay change failed")
mullvadActionStatusTimer.restart()
}
if (!mullvadRelayGetProc.running) mullvadRelayGetProc.running = true
// Relay constraint changes don't take effect on an already-open
// tunnel by themselves — kick a reconnect so switching location/
// multihop while connected actually moves the tunnel, instead of
// silently only applying on the next manual reconnect.
if (root.mullvadState === "connected" && !mullvadReconnectProc.running) mullvadReconnectProc.running = true
}
}
Process {
id: mullvadReconnectProc
command: ["mullvad", "reconnect"]
stdout: StdioCollector {}
stderr: StdioCollector {}
onExited: (exitCode, exitStatus) => root.refreshMullvad()
}
// --- ProtonVPN --------------------------------------------------------- // --- ProtonVPN ---------------------------------------------------------
Process { Process {
@@ -261,8 +453,8 @@ Item {
stderr: StdioCollector { id: protonConnectErr } stderr: StdioCollector { id: protonConnectErr }
onExited: (exitCode, exitStatus) => { onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) { if (exitCode !== 0) {
root.actionStatus = "ProtonVPN: " + root.shortLine(protonConnectErr.text || "connect failed") root.protonActionStatus = root.shortLine(protonConnectErr.text || "Connect failed")
actionStatusTimer.restart() protonActionStatusTimer.restart()
} else if (!tailscaleDownProc.running) { } else if (!tailscaleDownProc.running) {
tailscaleDownProc.running = true tailscaleDownProc.running = true
} }
@@ -277,8 +469,8 @@ Item {
stderr: StdioCollector { id: protonDisconnectErr } stderr: StdioCollector { id: protonDisconnectErr }
onExited: (exitCode, exitStatus) => { onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) { if (exitCode !== 0) {
root.actionStatus = "ProtonVPN: " + root.shortLine(protonDisconnectErr.text || "disconnect failed") root.protonActionStatus = root.shortLine(protonDisconnectErr.text || "Disconnect failed")
actionStatusTimer.restart() protonActionStatusTimer.restart()
} }
root.refreshProton() root.refreshProton()
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() } if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() }
@@ -305,10 +497,17 @@ Item {
} }
Timer { Timer {
id: actionStatusTimer id: mullvadActionStatusTimer
interval: 4000 interval: 4000
repeat: false repeat: false
onTriggered: root.actionStatus = "" onTriggered: root.mullvadActionStatus = ""
}
Timer {
id: protonActionStatusTimer
interval: 4000
repeat: false
onTriggered: root.protonActionStatus = ""
} }
// --- Bar icon ------------------------------------------------------------ // --- Bar icon ------------------------------------------------------------
@@ -416,7 +615,13 @@ Item {
property string detailText: "" property string detailText: ""
property string state: "disconnected" property string state: "disconnected"
property bool installed: true property bool installed: true
// When true, shows a chevron that expands a provider-specific
// options section below this row (used for Mullvad's advanced
// options; ProtonVPN's row leaves this false).
property bool expandable: false
property bool expanded: false
signal toggle() signal toggle()
signal expandToggle()
width: parent ? parent.width : 0 width: parent ? parent.width : 0
radius: 0 radius: 0
implicitHeight: rowContent.implicitHeight + 12 implicitHeight: rowContent.implicitHeight + 12
@@ -477,9 +682,91 @@ Item {
busy: provRow.state === "connecting" || provRow.state === "disconnecting" busy: provRow.state === "connecting" || provRow.state === "disconnecting"
onToggled: provRow.toggle() onToggled: provRow.toggle()
} }
Text {
visible: provRow.expandable
text: provRow.expanded ? "expand_less" : "expand_more"
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 15
color: expandMouse.containsMouse ? Colors.foreground : Colors.muted
MouseArea {
id: expandMouse
anchors.fill: parent
anchors.margins: -5
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: provRow.expandToggle()
}
}
} }
} }
// A single clickable "Location: Sweden ›" row inside the Mullvad
// options section — used for both the exit location and (when multihop
// is on) the entry location. Clicking routes back to root, which opens
// the shared country picker below rather than each field owning one.
component LocationField: Item {
id: locField
property string fieldLabel: ""
property string valueText: "Any"
property bool open: false
signal clicked()
width: parent ? parent.width : 0
implicitHeight: locRow.implicitHeight
RowLayout {
id: locRow
width: parent.width
spacing: 6
Text {
text: locField.fieldLabel
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
Text {
Layout.fillWidth: true
horizontalAlignment: Text.AlignRight
elide: Text.ElideRight
text: locField.valueText
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
font.bold: true
color: Colors.foreground
}
Text {
text: locField.open ? "expand_less" : "chevron_right"
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 14
color: Colors.muted
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: locField.clicked()
}
}
// A failed connect/disconnect/settings-change message, shown directly
// under the provider row it came from rather than in one shared line
// for both providers — keeps an error attributed to whichever CLI it
// actually came from.
component StatusLine: Text {
property string message: ""
visible: message !== ""
width: parent ? parent.width : 0
text: message
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
wrapMode: Text.WordWrap
}
// --- Panel ----------------------------------------------------------- // --- Panel -----------------------------------------------------------
PopupWindow { PopupWindow {
@@ -494,7 +781,18 @@ Item {
NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic } NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic }
} }
onVisibleChanged: if (visible) root.refresh() // Leaving mullvadOptionsOpen itself alone across opens (it's a
// deliberate "keep this expanded" choice), but the country picker
// is transient state scoped to one visit — closing the panel with
// it open would otherwise leave a stale filter/list waiting.
onVisibleChanged: {
if (visible) {
root.refresh()
} else {
root.mullvadPickerTarget = ""
root.mullvadPickerFilter = ""
}
}
anchor { anchor {
window: root.barWindow window: root.barWindow
@@ -548,28 +846,202 @@ Item {
} }
} }
Text {
visible: root.actionStatus !== ""
width: parent.width
text: root.actionStatus
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
wrapMode: Text.WordWrap
}
Column { Column {
width: parent.width width: parent.width
spacing: 2 spacing: 2
ProviderRow { // Mullvad's connect row and its advanced options share a
label: "Mullvad" // single bordered card — the border wraps the whole
subIcon: "gpp_good" // section (row, status line, and options together) and
state: root.mullvadState // grows to include the options/picker as they open,
detailText: root.mullvadDetail // rather than the options having a separate box of
installed: root.mullvadInstalled // their own below an unbordered connect row.
onToggle: root.toggleMullvad() Rectangle {
width: parent.width
radius: 0
color: Colors.background
border.color: Colors.muted
border.width: 1
implicitHeight: mullvadSectionColumn.implicitHeight + 16
clip: true
// Without this, the card snapped to its new size
// instantly while the popup around it was still
// animating open, so the border visibly lagged
// behind (or cut off) content like the country
// picker appearing/disappearing beneath it.
Behavior on implicitHeight {
NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic }
}
Column {
id: mullvadSectionColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: 8
spacing: 8
ProviderRow {
label: "Mullvad"
subIcon: "gpp_good"
state: root.mullvadState
detailText: root.mullvadDetail
installed: root.mullvadInstalled
expandable: root.mullvadInstalled
expanded: root.mullvadOptionsOpen
onToggle: root.toggleMullvad()
onExpandToggle: root.openMullvadOptions()
}
StatusLine { message: root.mullvadActionStatus }
// Advanced options — auto-connect, exit/entry
// relay location, multihop.
Column {
id: mullvadOptionsColumn
width: parent.width
visible: root.mullvadOptionsOpen && root.mullvadInstalled
spacing: 8
RowLayout {
width: parent.width
Text {
Layout.fillWidth: true
text: "Auto-connect"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.foreground
}
ToggleSwitch {
checked: root.mullvadAutoConnect
busy: mullvadAutoConnectSetProc.running
onToggled: root.setMullvadAutoConnect(!root.mullvadAutoConnect)
}
}
RowLayout {
width: parent.width
Text {
Layout.fillWidth: true
text: "Multihop"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.foreground
}
ToggleSwitch {
checked: root.mullvadMultihop
busy: mullvadRelaySetProc.running
onToggled: root.setMullvadMultihop(!root.mullvadMultihop)
}
}
LocationField {
fieldLabel: root.mullvadMultihop ? "Exit" : "Location"
valueText: root.mullvadLocationLabel
open: root.mullvadPickerTarget === "exit"
onClicked: root.openMullvadPicker("exit")
}
LocationField {
visible: root.mullvadMultihop
fieldLabel: "Entry"
valueText: root.mullvadEntryLabel
open: root.mullvadPickerTarget === "entry"
onClicked: root.openMullvadPicker("entry")
}
// Shared country picker for whichever LocationField
// was last clicked — filter box plus a capped,
// scrollable list ("Any" always pinned first).
Column {
width: parent.width
visible: root.mullvadPickerTarget !== ""
spacing: 6
Rectangle {
width: parent.width
implicitHeight: 24
radius: 0
color: Colors.surface
border.color: Colors.muted
border.width: 1
Text {
anchors.fill: parent
anchors.margins: 6
visible: mullvadFilterInput.text.length === 0
text: "Search country…"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
TextInput {
id: mullvadFilterInput
anchors.fill: parent
anchors.margins: 6
color: Colors.foreground
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
clip: true
focus: root.mullvadPickerTarget !== ""
text: root.mullvadPickerFilter
onTextChanged: root.mullvadPickerFilter = text
}
}
Flickable {
id: mullvadPickerFlick
width: parent.width
height: Math.min(mullvadPickerColumn.implicitHeight, 150)
contentWidth: width
contentHeight: mullvadPickerColumn.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
Column {
id: mullvadPickerColumn
width: mullvadPickerFlick.width
spacing: 1
Repeater {
model: [{ name: "Any", code: "any" }].concat(root.mullvadFilteredCountries)
Rectangle {
required property var modelData
width: parent.width
implicitHeight: 22
radius: 0
color: countryMouse.containsMouse ? Colors.primary : "transparent"
Text {
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.leftMargin: 4
anchors.rightMargin: 4
elide: Text.ElideRight
text: modelData.name
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: countryMouse.containsMouse ? Colors.primaryText : Colors.foreground
}
MouseArea {
id: countryMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.pickMullvadLocation(modelData.code)
}
}
}
}
}
}
}
}
} }
ProviderRow { ProviderRow {
label: "ProtonVPN" label: "ProtonVPN"
subIcon: "gpp_good" subIcon: "gpp_good"
@@ -578,6 +1050,8 @@ Item {
installed: root.protonInstalled installed: root.protonInstalled
onToggle: root.toggleProton() onToggle: root.toggleProton()
} }
StatusLine { message: root.protonActionStatus }
} }
Text { Text {

View File

@@ -0,0 +1,413 @@
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Hyprland
import "../../config"
// Weather indicator — modeled on omarchy's weather bar pill
// (github.com/omacom/omarchy, quattro branch,
// shell/plugins/panels/weather) but rebuilt in this bar's own style: the
// actual location/polling logic lives in the WeatherData singleton (config/),
// shared across every monitor's bar; this is just the icon + panel UI,
// same split as SystemUpdate.qml.
Item {
id: root
implicitWidth: row.implicitWidth + 2
implicitHeight: Appearance.barHeight
// The bar's own PanelWindow — same barWindow-anchoring convention as
// every other popup-having module here (Volume.qml, SystemUpdate.qml,
// etc.), so the panel opens in a fixed spot rather than following this
// icon's exact position in its RowLayout.
required property var barWindow
readonly property bool busy: WeatherData.locating || WeatherData.loading
// Local to this popup instance — unlike WeatherData's own state, there's
// no need to share an in-progress search across monitors' bars.
property bool searchOpen: false
property string searchQuery: ""
RowLayout {
id: row
anchors.centerIn: parent
spacing: 4
Text {
id: label
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 18
color: Colors.foreground
// Same spinner treatment as SystemUpdate.qml/
// BluetoothIndicator.qml's scan spinner while the location or
// conditions are still loading.
text: root.busy ? "progress_activity" : (WeatherData.hasLocation ? WeatherData.iconFor(WeatherData.weatherCode, WeatherData.isDay) : "cloud_off")
RotationAnimation on rotation {
running: root.busy
loops: Animation.Infinite
from: 0
to: 360
duration: 900
onRunningChanged: if (!running) label.rotation = 0
}
}
Text {
visible: WeatherData.hasLocation && !WeatherData.locating
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.foreground
text: Math.round(WeatherData.tempC) + "°"
}
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: (mouse) => {
if (mouse.button === Qt.RightButton) {
WeatherData.refresh()
} else {
panel.visible = !panel.visible
grab.active = panel.visible
}
}
}
PopupWindow {
id: panel
visible: false
color: "transparent"
// Same contentWidth/contentPadding split as the other panels
// (Volume.qml, SystemUpdate.qml, etc.) rather than a single magic
// implicitWidth.
readonly property int contentWidth: 220
readonly property int contentPadding: 16
implicitWidth: contentWidth + contentPadding * 2
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
anchor {
window: root.barWindow
// Unlike anchor.item, anchor.window doesn't default rect to the
// window's own bounds — set it explicitly so edges/gravity below
// resolve against the full bar. Bottom alone (no Left/Right)
// anchors to the bar's bottom-center, which lines up with this
// icon since it sits in the bar's centered RowLayout.
rect.x: 0
rect.y: 0
rect.width: root.barWindow.width
rect.height: root.barWindow.height
edges: Edges.Bottom
gravity: Edges.Bottom
margins.bottom: -4
}
HyprlandFocusGrab {
id: grab
windows: [panel]
onCleared: panel.visible = false
}
Rectangle {
anchors.fill: parent
radius: 0
color: Colors.surface
border.color: Colors.muted
border.width: 3
Column {
id: mainColumn
anchors.fill: parent
anchors.margins: panel.contentPadding
spacing: 10
RowLayout {
width: parent.width
spacing: 8
Text {
text: label.text
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 28
color: Colors.foreground
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
Text {
Layout.fillWidth: true
elide: Text.ElideRight
text: {
if (WeatherData.locating) return "Locating…"
if (!WeatherData.hasLocation) return "Location unavailable"
return WeatherData.locationName || "Weather"
}
font.family: Appearance.fontFamily
font.pixelSize: 18
font.bold: true
color: Colors.foreground
}
Text {
visible: WeatherData.hasLocation && !WeatherData.locating
text: WeatherData.loading ? "Updating…" : "Feels like " + Math.round(WeatherData.feelsLikeC) + "°"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
}
Text {
visible: WeatherData.hasLocation && !WeatherData.locating
text: Math.round(WeatherData.tempC) + "°"
font.family: Appearance.fontFamily
font.pixelSize: 28
font.bold: true
color: Colors.foreground
}
// Toggles the search box below — same tap target size
// and hover feedback as the picker-target rows in
// VpnIndicator.qml.
Text {
text: root.searchOpen ? "close" : "edit_location"
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 16
color: editLocationMouse.containsMouse ? Colors.primary : Colors.muted
MouseArea {
id: editLocationMouse
anchors.fill: parent
anchors.margins: -4
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
root.searchOpen = !root.searchOpen
root.searchQuery = ""
WeatherData.searchResults = []
}
}
}
}
// City search — always available regardless of how the
// current location was resolved (IP guess or a previous
// search), so a wrong auto-detected city is one click away
// from being fixed. Same search-box + result-list shape as
// VpnIndicator.qml's Mullvad location picker.
Column {
visible: root.searchOpen
width: parent.width
spacing: 6
Rectangle {
width: parent.width
implicitHeight: 24
radius: 0
color: Colors.surface
border.color: Colors.muted
border.width: 1
Text {
anchors.fill: parent
anchors.margins: 6
visible: citySearchInput.text.length === 0
text: "Search city…"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
TextInput {
id: citySearchInput
anchors.fill: parent
anchors.margins: 6
color: Colors.foreground
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
clip: true
focus: root.searchOpen
text: root.searchQuery
onTextChanged: {
root.searchQuery = text
searchDebounce.restart()
}
}
Timer {
id: searchDebounce
interval: 400
onTriggered: WeatherData.search(root.searchQuery)
}
}
Text {
visible: WeatherData.searching
text: "Searching…"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
Text {
visible: !WeatherData.searching && citySearchInput.text.length >= 2 && WeatherData.searchResults.length === 0
text: "No matches"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
Flickable {
width: parent.width
height: Math.min(resultsColumn.implicitHeight, 150)
contentWidth: width
contentHeight: resultsColumn.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
Column {
id: resultsColumn
width: parent.width
spacing: 1
Repeater {
model: WeatherData.searchResults
delegate: Rectangle {
required property var modelData
width: parent.width
implicitHeight: resultText.implicitHeight + 8
radius: 0
color: resultMouse.containsMouse ? Colors.primary : "transparent"
Text {
id: resultText
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: 4
elide: Text.ElideRight
text: modelData.description ? (modelData.name + " — " + modelData.description) : modelData.name
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: resultMouse.containsMouse ? Colors.primaryText : Colors.foreground
}
MouseArea {
id: resultMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: {
WeatherData.selectLocation(modelData.name, modelData.latitude, modelData.longitude)
root.searchOpen = false
root.searchQuery = ""
WeatherData.searchResults = []
}
}
}
}
}
}
}
// Humidity / wind — same two-stat layout, just laid out as
// a row of icon+value pairs rather than full sentences.
RowLayout {
visible: WeatherData.hasLocation && !WeatherData.locating
width: parent.width
spacing: 16
RowLayout {
spacing: 4
Text {
text: "water_drop"
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 14
color: Colors.muted
}
Text {
text: WeatherData.humidity + "%"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.foreground
}
}
RowLayout {
spacing: 4
Text {
text: "air"
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 14
color: Colors.muted
}
Text {
text: Math.round(WeatherData.windKmh) + " km/h"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.foreground
}
}
}
Rectangle {
visible: WeatherData.forecast.length > 0
width: parent.width
height: 1
color: Colors.muted
}
RowLayout {
visible: WeatherData.forecast.length > 0
width: parent.width
spacing: 4
Repeater {
model: WeatherData.forecast
delegate: ColumnLayout {
required property var modelData
Layout.fillWidth: true
spacing: 2
Text {
Layout.alignment: Qt.AlignHCenter
text: Qt.formatDateTime(new Date(modelData.date + "T12:00:00"), "ddd")
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
Text {
Layout.alignment: Qt.AlignHCenter
text: WeatherData.iconFor(modelData.code, true)
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 18
color: Colors.foreground
}
Text {
Layout.alignment: Qt.AlignHCenter
text: Math.round(modelData.maxC) + "°/" + Math.round(modelData.minC) + "°"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.foreground
}
}
}
}
}
}
}
}

View File

@@ -11,16 +11,13 @@ PanelWindow {
color: "transparent" color: "transparent"
exclusiveZone: 0 exclusiveZone: 0
// Top-right, just under the bar — same fixed distance from the bar and // Bottom-center: anchoring only the bottom edge lets the layer shell
// screen edge (Appearance.spacing) that every popup opened from the bar // center it horizontally.
// itself uses, so this lines up with them instead of floating anchors { bottom: true }
// bottom-center. margins.bottom: Appearance.spacing * 4
anchors { top: true; right: true }
margins.top: Appearance.spacing
margins.right: Appearance.spacing
implicitWidth: 272 implicitWidth: 300
implicitHeight: 54 implicitHeight: 60
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "quickshell:osd" WlrLayershell.namespace: "quickshell:osd"
@@ -49,13 +46,13 @@ PanelWindow {
RowLayout { RowLayout {
anchors.fill: parent anchors.fill: parent
anchors.margins: 14 anchors.margins: 15
spacing: 12 spacing: 13
Text { Text {
font.family: Appearance.iconFontFamilyRounded font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 18 font.pixelSize: 21
color: Colors.foreground color: Colors.foreground
text: { text: {
const audio = osd.sink ? osd.sink.audio : null const audio = osd.sink ? osd.sink.audio : null
@@ -67,7 +64,7 @@ PanelWindow {
Item { Item {
id: slider id: slider
Layout.fillWidth: true Layout.fillWidth: true
implicitHeight: 16 implicitHeight: 18
readonly property real value: osd.sink && osd.sink.audio ? osd.sink.audio.volume : 0 readonly property real value: osd.sink && osd.sink.audio ? osd.sink.audio.volume : 0
readonly property bool enabled: !!osd.sink readonly property bool enabled: !!osd.sink
@@ -76,7 +73,7 @@ PanelWindow {
id: track id: track
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
width: parent.width width: parent.width
height: 4 height: 5
radius: 0 radius: 0
color: Colors.muted color: Colors.muted
opacity: 0.35 opacity: 0.35
@@ -85,15 +82,15 @@ PanelWindow {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left anchors.left: parent.left
width: track.width * Math.max(0, Math.min(1, slider.value)) width: track.width * Math.max(0, Math.min(1, slider.value))
height: 4 height: 5
radius: 0 radius: 0
color: Colors.primary color: Colors.primary
opacity: slider.enabled ? 1 : 0.4 opacity: slider.enabled ? 1 : 0.4
} }
Rectangle { Rectangle {
width: 12 width: 14
height: 12 height: 14
radius: 6 radius: 7
color: Colors.primary color: Colors.primary
opacity: slider.enabled ? 1 : 0.4 opacity: slider.enabled ? 1 : 0.4
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@@ -113,10 +110,10 @@ PanelWindow {
} }
Text { Text {
Layout.preferredWidth: 32 Layout.preferredWidth: 38
horizontalAlignment: Text.AlignRight horizontalAlignment: Text.AlignRight
font.family: Appearance.fontFamily font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall font.pixelSize: 13
color: Colors.muted color: Colors.muted
text: Math.round(slider.value * 100) + "%" text: Math.round(slider.value * 100) + "%"
} }

View File

@@ -0,0 +1,50 @@
#!/bin/bash
# Run by update-system.sh inside the update terminal, before the system
# update — pulls the dotfiles repo and re-stows it so both machines pick up
# config changes on every update.
#
# Never fails the update: a pull that can't fast-forward (local commits or
# uncommitted changes in the way) or a stow conflict just prints a warning
# and leaves things as they were, then the system update carries on.
DOTFILES="$HOME/Projects/dotfiles"
warn() { printf '\033[1;33m%s\033[0m\n' "$*"; }
info() { printf '\033[1;36m%s\033[0m\n' "$*"; }
if [ ! -d "$DOTFILES/.git" ]; then
warn "Dotfiles repo not found at $DOTFILES — skipping."
exit 0
fi
info "Pulling dotfiles..."
# Fetch and merge separately so the warning says which one went wrong — the
# remote is SSH via Bitwarden's agent, so a failed fetch is almost always
# Bitwarden being closed or locked.
if ! git -C "$DOTFILES" fetch; then
warn "Couldn't reach the dotfiles remote — is Bitwarden open and unlocked? Skipping stow."
echo
exit 0
fi
# --ff-only so a diverged branch never produces a surprise merge commit;
# sort that out by hand instead.
if ! git -C "$DOTFILES" merge --ff-only; then
warn "Couldn't fast-forward dotfiles (local changes or diverged branch) — skipping stow."
echo
exit 0
fi
if ! command -v stow &> /dev/null; then
warn "stow not installed — skipping."
exit 0
fi
info "Applying dotfiles with stow..."
# -R restows: drops links for files removed from the repo and adds new ones.
if stow -R -d "$DOTFILES" -t "$HOME" dotfiles; then
info "Dotfiles applied."
else
warn "stow hit a conflict — nothing was changed. Fix it and re-run:"
warn " stow -R -d $DOTFILES -t ~ dotfiles"
fi
echo

View File

@@ -0,0 +1,66 @@
#!/bin/bash
# Launched by SystemUpdate.qml (top bar, next to the clock) — opens a
# terminal running whatever update command fits this distro, then notifies
# once it's done. Ported from the old waybar custom/updater module
# (.config/waybar/scripts/update-distro.sh); kept multi-distro even though
# this machine is Arch, since these dotfiles aren't.
#
# The window it opens is floated + centered by a matching Hyprland rule
# (system-update-terminal, in .config/hypr/UserConfigs/WindowRules.conf),
# same treatment as Bitwarden's window, rather than tiling into whatever
# workspace you happened to be on.
iDIR="$HOME/.config/swaync/images"
if ! command -v foot &> /dev/null; then
notify-send -i "$iDIR/error.png" "Need foot:" "foot terminal not found. Please install foot terminal."
exit 1
fi
# Printed inside the terminal before the actual update command runs, so it
# reads as "updating" from the first frame rather than looking like an
# empty/stuck window while pacman/apt/etc. take a moment to start printing.
#
# foot runs this in a fresh `bash -c`, which doesn't inherit shell functions
# from this script's own environment (export -f only reaches bash processes
# forked from *this* one, not whatever foot execs) — so instead, the
# already-colored text is built once here with real escape bytes ($'...'),
# then %q-quoted into a self-contained `echo '...'` that reproduces those
# same bytes verbatim when the child bash parses it.
msg=" Updating System — please wait "
line=$(printf '=%.0s' $(seq 1 ${#msg}))
banner_text=$'\033[1;36m'"$line"$'\033[0m\n\033[1;36m'"$msg"$'\033[0m\n\033[1;36m'"$line"$'\033[0m\n\n'
BANNER="echo $(printf '%q' "$banner_text")"
if command -v paru &> /dev/null; then
CMD="paru -Syu"
LABEL="Arch-based system"
elif command -v yay &> /dev/null; then
CMD="yay -Syu"
LABEL="Arch-based system"
elif command -v dnf &> /dev/null; then
CMD="sudo dnf update --refresh -y"
LABEL="Fedora system"
elif command -v apt &> /dev/null; then
CMD="sudo apt update && sudo apt upgrade -y"
LABEL="Debian/Ubuntu system"
elif command -v zypper &> /dev/null; then
CMD="sudo zypper dup -y"
LABEL="openSUSE system"
else
notify-send -i "$iDIR/error.png" -u critical "Unsupported system" "This script does not support your distribution."
exit 1
fi
# Keeps the terminal open (whether the update succeeded or failed — read
# always runs regardless, since it's ; not &&) until you press Enter,
# instead of foot closing the instant the update command exits and taking
# any output/errors with it.
WAIT_PROMPT="echo; read -p 'Update finished — press Enter to close...'"
# Pull + re-stow the dotfiles first, so config changes from the other machine
# land alongside the package update.
SYNC="$(printf '%q' "$(dirname "$0")/sync-dotfiles.sh")"
foot -T update bash -c "$BANNER; $SYNC; $CMD; $WAIT_PROMPT"
notify-send -i "$iDIR/profile-pic.jpg" -u low "$LABEL" 'has been updated.'

View File

@@ -4,7 +4,11 @@ import "./modules/osd"
ShellRoot { ShellRoot {
Variants { Variants {
model: Quickshell.screens // Exclude the Sunshine headless virtual monitor — nobody ever sees a
// bar there, but PanelWindow's exclusiveZone still reserves space for
// it, which left a black strip cut out of Steam Big Picture's
// fullscreen area on that output.
model: Quickshell.screens.filter(s => !s.name.startsWith("HEADLESS"))
Bar { Bar {
required property var modelData required property var modelData

View File

@@ -13,7 +13,7 @@
"tooltip-format": "{ipaddr} via {gwaddr}", "tooltip-format": "{ipaddr} via {gwaddr}",
"tooltip-format-disconnected": "Disconnected", "tooltip-format-disconnected": "Disconnected",
"max-length": 50, "max-length": 50,
"on-click": "orbit toggle top-right", "on-click": "nm-connection-editor",
"on-click-right": "kitty -e nmtui", "on-click-right": "kitty -e nmtui",
"format-icons": { "format-icons": {
"wifi": ["󰤯", "󰤟", "󰤢", "󰤥", "󰤨"], "wifi": ["󰤯", "󰤟", "󰤢", "󰤥", "󰤨"],

13
dotfiles/.local/bin/Plex Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# Wrapper for the plex-desktop package's /usr/bin/Plex (-> /opt/plex-desktop/Plex.sh).
#
# QT_QUICK_CONTROLS_STYLE=org.hyprland.style is set globally in
# hypr/core/env.lua for other Qt/QML apps' theming (hyprland-qt-support), but
# Plex Desktop bundles its own private Qt/QML runtime that doesn't ship that
# style plugin — inheriting the var makes it fail to load its UI and exit
# immediately with "module \"org.hyprland.style\" is not installed" (see
# ~/.local/share/plex/Logs/Plex.log). This dir is first on $PATH, so it
# shadows /usr/bin/Plex for both a terminal launch and the .desktop entry
# (Exec=Plex is PATH-resolved) without touching the global env var.
unset QT_QUICK_CONTROLS_STYLE
exec /opt/plex-desktop/Plex.sh "$@"

View File

@@ -0,0 +1,26 @@
# User override of /usr/share/applications/tv.plex.PlexDesktop.desktop
# (~/.local/share/applications takes XDG precedence over /usr/share for the
# same filename, so this is picked up by app launchers instead automatically
# — no association/mimeapps changes needed).
#
# Only change from upstream: Exec unsets QT_QUICK_CONTROLS_STYLE and calls
# the package's Plex.sh directly, bypassing $PATH entirely. Plex Desktop
# bundles its own private Qt/QML runtime that doesn't ship the
# "org.hyprland.style" QQC2 style hypr/core/env.lua sets globally for other
# apps' theming — inheriting it makes Plex fail to load its UI and exit
# immediately with "module \"org.hyprland.style\" is not installed" (see
# ~/.local/share/plex/Logs/Plex.log). Going straight to the fixed install
# path (rather than relying on the .local/bin/Plex wrapper shadowing
# /usr/bin/Plex on $PATH) means this keeps working even if $PATH order ever
# changes.
[Desktop Entry]
Type=Application
Name=Plex
Comment=Plex client for desktop computers
Exec=env -u QT_QUICK_CONTROLS_STYLE /opt/plex-desktop/Plex.sh
Icon=tv.plex.PlexDesktop
Terminal=false
Categories=AudioVideo;Player;X-Media;
X-Flatpak=tv.plex.PlexDesktop
StartupWMClass=Plex
Name[en_US]=Plex

14
dotfiles/.ssh/config Normal file
View File

@@ -0,0 +1,14 @@
Host 192.168.1.87
HostName 192.168.1.87
User thoams
ForwardAgent yes
Host 192.168.1.83
HostName 192.168.1.83
User thoams
ForwardAgent yes
Host 192.168.1.86
HostName 192.168.1.86
User thoams
ForwardAgent yes

View File

@@ -23,7 +23,7 @@ source $ZSH/oh-my-zsh.sh
# My alias # My alias
alias cl='clear' alias cl='clear && fastfetch'
alias zed='zeditor' alias zed='zeditor'
alias ssh-gavin='ssh thoams@192.168.1.83' alias ssh-gavin='ssh thoams@192.168.1.83'
alias ssh-raspberry-pi='ssh thoams@192.168.1.86' alias ssh-raspberry-pi='ssh thoams@192.168.1.86'
@@ -51,3 +51,4 @@ export ZED_ALLOW_EMULATED_GPU=1
fastfetch fastfetch
eval "$(oh-my-posh init zsh --config $HOME/.config/ohmyposh/zen.toml)" eval "$(oh-my-posh init zsh --config $HOME/.config/ohmyposh/zen.toml)"
export PATH="$HOME/.local/share/turritopsis/flutter-sdk/bin:$PATH"

11
system/install.sh Executable file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
# Installs system-level config that stow can't manage (needs root).
set -euo pipefail
repo="$(cd "$(dirname "$0")" && pwd)"
# pacman: shared [options] snippet
sudo install -m644 "$repo/pacman/options.conf" /etc/pacman.d/options.conf
if ! grep -q '^Include = /etc/pacman.d/options.conf' /etc/pacman.conf; then
sudo sed -i '/^\[options\]/a Include = /etc/pacman.d/options.conf' /etc/pacman.conf
fi

View File

@@ -0,0 +1,5 @@
# Shared pacman [options], included from /etc/pacman.conf.
# Installed to /etc/pacman.d/options.conf by system/install.sh.
Color
#ILoveCandy
#VerbosePkgLists