42 Commits

Author SHA1 Message Date
ccae379490 Feature: Ctrl+C/Ctrl+V copy and paste in foot
Ctrl+C and Ctrl+V now copy and paste like in other apps. The interrupt
signal (^C) moves to Ctrl+Shift+C.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 06:33:49 +01:00
7f0fad5922 Fix: Run Plex under XWayland to stop a blank window
hypr env sets QT_QPA_PLATFORM="wayland;xcb", and under Wayland Plex's
bundled QtWebEngine never loads the web client, so the window stays
blank. Both the PATH wrapper and the .desktop entry now force xcb, as
upstream does, and set XCURSOR_SIZE=24 so the X11 cursor isn't huge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 06:33:12 +01:00
b40d4a9fa4 Fix: Point Sunshine tray link at new tailnet domain
The tailnet's MagicDNS suffix changed from twhomelab.local to
twhomelab.tailnet, so the Sunshine web UI link now opens
xol.twhomelab.tailnet:47990.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 06:33:12 +01:00
19d6936aa6 Feature: Enable hypridle without locking during Sunshine streams
Sunshine's global_prep_cmd now runs hypr/scripts/SunshineIdle.sh, which
holds a systemd idle inhibitor for the length of a stream (hypridle
respects it) and unlocks hyprlock if the session was already locked.
With that in place hypridle starts at login again.

Sunshine's config (sunshine.conf, apps.json, prep scripts) moves into
the dotfiles; credentials, logs and state stay local and are gitignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:14:44 +01:00
4a57e9902f Feature: Bootstrap script for new installs
bootstrap.sh installs everything listed in system/packages.txt (pacman,
AUR via yay, flatpak, official install scripts), auto-detects CPU
microcode and GPU drivers, enables services, links dotfiles with stow
and runs system/install.sh. Optional --svt-av1 installs
svt-av1-essential-git and rebuilds ffmpeg against it via
system/rebuild-ffmpeg.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:10:27 +01:00
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
03b043d9d3 Merge branch 'features/adding_quickshell' of ssh://gitea.thomasleewilliams.co.uk:2222/thomas/dotfiles into features/adding_quickshell 2026-08-27 14:51:01 +01:00
1ff92f3b79 Merge pull request 'features/monitor_options' (#5) from features/monitor_options into features/adding_quickshell
Reviewed-on: #5
2026-08-27 14:46:07 +01:00
c64cf5ff12 Fix: Restored "time to charged" 2026-08-27 14:11:27 +01:00
9e532023bd Fix: Corrected battery behavior 2026-08-27 14:06:21 +01:00
51 changed files with 2834 additions and 273 deletions

View File

@@ -39,13 +39,26 @@ mkdir Projects
git clone ssh://git@gitea.thomasleewilliams.co.uk:2222/thomas/dotfiles.git
```
Once that is complete, run this command:
On a fresh install, run the bootstrap script. It installs drivers (auto-detected),
every package in `system/packages.txt`, yay, flatpaks, then links the dotfiles and
system config below:
```console
~/Projects/dotfiles/bootstrap.sh # asks about the optional SVT-AV1 + ffmpeg rebuild
~/Projects/dotfiles/bootstrap.sh --svt-av1 # or --no-svt-av1 to skip the question
```
To only link the dotfiles, run this command:
```console
stow -d ~/Projects/dotfiles/ -t ~/ dotfiles
```
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
List of programs and tools I use.

168
bootstrap.sh Executable file
View File

@@ -0,0 +1,168 @@
#!/usr/bin/env bash
# Sets up a fresh Arch install: drivers, packages, dotfiles and system config.
# Safe to re-run; anything already installed is skipped.
#
# Usage: ./bootstrap.sh [--svt-av1 | --no-svt-av1]
# --svt-av1 also install svt-av1-essential-git and rebuild ffmpeg against it
# --no-svt-av1 skip that without asking
# (neither) ask
set -euo pipefail
repo="$(cd "$(dirname "$0")" && pwd)"
list="$repo/system/packages.txt"
svt_av1=ask
for arg in "$@"; do
case "$arg" in
--svt-av1) svt_av1=yes ;;
--no-svt-av1) svt_av1=no ;;
*) echo "Unknown option: $arg" >&2; exit 1 ;;
esac
done
if (( EUID == 0 )); then
echo "Run this as your normal user, not root (it uses sudo where needed)." >&2
exit 1
fi
step() { printf '\n\033[1;34m==> %s\033[0m\n' "$*"; }
# Prints the packages under a [section] of packages.txt, skipping comments
packages() {
awk -v want="[$1]" '
/^\[/ { section = $1; next }
section == want { sub(/#.*/, ""); if (NF) print $1 }
' "$list"
}
# --- System config -----------------------------------------------------------
step "System config (pacman options)"
"$repo/system/install.sh"
step "Enabling multilib (needed for Steam and 32-bit drivers)"
if ! grep -q '^\[multilib\]' /etc/pacman.conf; then
sudo sed -i '/^#\[multilib\]/,/^#Include/ s/^#//' /etc/pacman.conf
fi
sudo pacman -Syu --needed --noconfirm pciutils git base-devel
# --- Hardware ----------------------------------------------------------------
step "Detecting CPU and GPU"
hw=()
case "$(grep -m1 '^vendor_id' /proc/cpuinfo | awk '{print $3}')" in
AuthenticAMD) hw+=(amd-ucode); echo "CPU: AMD" ;;
GenuineIntel) hw+=(intel-ucode); echo "CPU: Intel" ;;
*) echo "CPU: unknown vendor, skipping microcode" ;;
esac
# Vendor column of every display controller, e.g. "NVIDIA Corporation"
gpus="$(lspci -mm | awk -F'"' '$2 ~ /VGA|3D|Display/ { print $4 }')"
if grep -q NVIDIA <<<"$gpus"; then
echo "GPU: NVIDIA"
# nvidia-open only ships modules for the stock `linux` kernel; any other
# kernel needs the DKMS build. Supports Turing (GTX 16xx / RTX 20xx) and newer.
if pacman -Qq linux-lts linux-zen linux-hardened >/dev/null 2>&1; then
hw+=(nvidia-open-dkms linux-headers)
else
hw+=(nvidia-open)
fi
hw+=(nvidia-utils lib32-nvidia-utils libva-nvidia-driver)
fi
if grep -q 'Advanced Micro Devices' <<<"$gpus"; then
echo "GPU: AMD"
hw+=(mesa lib32-mesa vulkan-radeon lib32-vulkan-radeon)
fi
if grep -q 'Intel Corporation' <<<"$gpus"; then
echo "GPU: Intel"
hw+=(mesa lib32-mesa vulkan-intel lib32-vulkan-intel intel-media-driver)
fi
# Installed before the main list so Steam's 32-bit Vulkan dependency is
# already satisfied by the right driver
if (( ${#hw[@]} )); then
sudo pacman -S --needed --noconfirm "${hw[@]}"
fi
# --- Packages ----------------------------------------------------------------
step "Official repo packages"
mapfile -t pkgs < <(packages pacman)
sudo pacman -S --needed --noconfirm "${pkgs[@]}"
step "yay (AUR helper)"
if ! command -v yay >/dev/null; then
build="$(mktemp -d)"
git clone https://aur.archlinux.org/yay-bin.git "$build/yay-bin"
(cd "$build/yay-bin" && makepkg -si --noconfirm)
rm -rf "$build"
else
echo "already installed"
fi
step "AUR packages"
mapfile -t pkgs < <(packages aur)
yay -S --needed --noconfirm "${pkgs[@]}"
step "Flatpak apps"
sudo flatpak remote-add --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
mapfile -t pkgs < <(packages flatpak)
sudo flatpak install -y --noninteractive flathub "${pkgs[@]}"
step "Apps from official install scripts"
mkdir -p "$HOME/.local/bin"
for app in $(packages installer); do
if command -v "$app" >/dev/null || [[ -x "$HOME/.local/bin/$app" ]]; then
echo "$app: already installed"
continue
fi
case "$app" in
claude) curl -fsSL https://claude.ai/install.sh | bash ;;
oh-my-posh) curl -fsSL https://ohmyposh.dev/install.sh | bash -s -- -d "$HOME/.local/bin" ;;
*) echo "$app: no install recipe in bootstrap.sh, skipping" >&2 ;;
esac
done
# --- Optional: SVT-AV1 essential + ffmpeg rebuild ----------------------------
if [[ $svt_av1 == ask ]]; then
read -rp $'\nInstall svt-av1-essential-git and rebuild ffmpeg against it? (slow) [y/N] ' reply
[[ $reply == [yY]* ]] && svt_av1=yes || svt_av1=no
fi
if [[ $svt_av1 == yes ]]; then
step "svt-av1-essential-git"
# Interactive on purpose: pacman asks to replace the conflicting svt-av1,
# and --noconfirm would answer no
yay -S --needed svt-av1-essential-git
step "Rebuilding ffmpeg against svt-av1-essential-git"
"$repo/system/rebuild-ffmpeg.sh"
fi
# --- Services ----------------------------------------------------------------
step "Enabling services"
# Not started now: sddm would take over the screen mid-script. They come up on reboot.
mapfile -t units < <(packages services)
sudo systemctl enable "${units[@]}"
# --- Dotfiles ----------------------------------------------------------------
step "Linking dotfiles with stow"
# Create real parent dirs first, otherwise stow symlinks the whole directory
# (e.g. ~/.config) into the repo and every app's files end up in git
mkdir -p "$HOME/.config" "$HOME/.local/bin" "$HOME/.local/share" "$HOME/Pictures"
# Sunshine keeps credentials and logs beside its config; those must stay out of git
mkdir -p "$HOME/.config/sunshine"
mkdir -p -m 700 "$HOME/.ssh"
if ! stow -d "$repo" -t "$HOME" --restow dotfiles; then
echo "stow hit conflicts (listed above). Move those files aside, then run:" >&2
echo " stow -d \"$repo\" -t \"\$HOME\" dotfiles" >&2
fi
if [[ "$(getent passwd "$USER" | cut -d: -f7)" != */zsh ]]; then
step "Setting zsh as login shell"
chsh -s /usr/bin/zsh
fi
step "Done — reboot to load drivers and start Hyprland"

View File

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

View File

@@ -47,3 +47,11 @@ regular4=53BDFA # blue
regular5=FAE994 # magenta
regular6=99faf2 # cyan
regular7=C7C7C7 # white
[key-bindings]
clipboard-copy=Control+c XF86Copy
clipboard-paste=Control+v Control+Shift+v XF86Paste
[text-bindings]
# Ctrl+C now copies, so send the interrupt signal (^C) from Ctrl+Shift+C instead
\x03=Control+Shift+c

View File

@@ -3,5 +3,6 @@ file:///home/thomas/Documents
file:///home/thomas/Downloads
file:///home/thomas/Pictures
file:///home/thomas/Music
file:///home/thomas/Videos
smb://192.168.1.84/media/
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
# See hyprland wiki https://wiki.hyprland.org/Nvidia/#environment-variables
#env = LIBVA_DRIVER_NAME,nvidia
#env = __GLX_VENDOR_LIBRARY_NAME,nvidia
#env = NVD_BACKEND,direct
#env = GSK_RENDERER,ngl
env = LIBVA_DRIVER_NAME,nvidia
env = __GLX_VENDOR_LIBRARY_NAME,nvidia
env = NVD_BACKEND,direct
env = GSK_RENDERER,ngl
# additional ENV's for nvidia. Caution, activate with care
#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_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.
# 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
active_opacity = 1.0
inactive_opacity = 0.9
inactive_opacity = 1.0
fullscreen_opacity = 1.0
dim_inactive = true

View File

@@ -8,5 +8,5 @@
# <<<<< TUI AUTO START
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: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

View File

@@ -24,7 +24,7 @@ hl.config({
rounding = 10,
active_opacity = 1.0,
inactive_opacity = 0.9,
inactive_opacity = 1.0,
fullscreen_opacity = 1.0,
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.
-- See hyprland wiki https://wiki.hyprland.org/Nvidia/#environment-variables
-- hl.env("LIBVA_DRIVER_NAME", "nvidia")
-- hl.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
-- hl.env("NVD_BACKEND", "direct")
-- hl.env("GSK_RENDERER", "ngl")
hl.env("LIBVA_DRIVER_NAME", "nvidia")
hl.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia")
hl.env("NVD_BACKEND", "direct")
hl.env("GSK_RENDERER", "ngl")
-- additional ENV's for nvidia. Caution, activate with care
-- 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_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.
-- 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…"
-- 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({
general = {
allow_tearing = true,
@@ -13,12 +18,12 @@ hl.config({
},
},
misc = {
vrr = 1,
vrr = 0,
},
})
-- 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
-- (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("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)
hl.exec_cmd(scriptsDir .. "/Polkit.sh")
@@ -23,7 +26,7 @@ hl.on("hyprland.start", function()
-- hl.exec_cmd("ags")
-- hl.exec_cmd("blueman-applet")
-- hl.exec_cmd("rog-control-center")
hl.exec_cmd("waybar")
-- hl.exec_cmd("waybar")
hl.exec_cmd("qs") -- quickshell AGS Desktop Overview alternative
-- clipboard manager
@@ -31,8 +34,12 @@ hl.on("hyprland.start", function()
hl.exec_cmd("wl-paste --type image --watch cliphist store")
-- Starting hypridle to start hyprlock
-- Sunshine streams hold an idle inhibitor (scripts/SunshineIdle.sh) so this never locks mid-stream
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)
hl.exec_cmd(scriptsDir .. "/FloatBitwardenPopup.sh")
@@ -43,6 +50,7 @@ hl.on("hyprland.start", function()
hl.exec_cmd("sleep 3 && vesktop")
hl.dispatch(hl.dsp.focus({ workspace = 1 }))
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
-- 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: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
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
unbind = SUPER SHIFT, Print

View File

@@ -10,5 +10,6 @@
-- <<<<< 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: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

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)" } })
-- 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-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-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.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-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)" } })
@@ -162,17 +162,17 @@ hl.window_rule({ name = "windowrule-92", float = true, center = true, size = "mo
-- END of float popups and dialogue #######
-- OPACITY
-- hl.window_rule({ opacity = "0.9 0.7", match = { tag = "browser*" } })
-- hl.window_rule({ opacity = "0.9 0.8", match = { tag = "projects*" } })
-- hl.window_rule({ opacity = "0.94 0.86", match = { tag = "im*" } })
-- hl.window_rule({ opacity = "0.94 0.86", match = { tag = "multimedia*" } })
-- hl.window_rule({ opacity = "0.9 0.8", match = { tag = "file-manager*" } })
hl.window_rule({ name = "windowrule-93", opacity = "0.8 0.7", match = { tag = "terminal*" } })
-- hl.window_rule({ opacity = "0.82 0.75", 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-95", opacity = "0.9 0.8", match = { class = "^(deluge)$" } })
hl.window_rule({ name = "windowrule-96", opacity = "0.9 0.8", match = { class = "^(seahorse)$" } }) -- gnome-keyring gui
-- hl.window_rule({ opacity = "0.95 0.75", match = { title = "^(Picture-in-Picture)$" } })
-- hl.window_rule({ opacity = "0.9 0.9", match = { tag = "browser*" } })
-- hl.window_rule({ opacity = "0.9 0.9", match = { tag = "projects*" } })
-- hl.window_rule({ opacity = "0.94 0.94", match = { tag = "im*" } })
-- hl.window_rule({ opacity = "0.94 0.94", match = { tag = "multimedia*" } })
-- hl.window_rule({ opacity = "0.9 0.9", match = { tag = "file-manager*" } })
hl.window_rule({ name = "windowrule-93", opacity = "0.8 0.8", match = { tag = "terminal*" } })
-- hl.window_rule({ opacity = "0.82 0.82", match = { tag = "viewer*" } })
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.9", match = { class = "^(deluge)$" } })
hl.window_rule({ name = "windowrule-96", opacity = "0.9 0.9", match = { class = "^(seahorse)$" } }) -- gnome-keyring gui
-- hl.window_rule({ opacity = "0.95 0.95", match = { title = "^(Picture-in-Picture)$" } })
-- SIZE
-- 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 = 2, monitor = "DP-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 = 5, monitor = "DP-2" })
-- hl.workspace_rule({ workspace = 6, monitor = "DP-2" })

View File

@@ -0,0 +1,29 @@
#!/bin/bash
# Keeps hypridle from locking the screen while Sunshine is streaming.
# Hooked up as Sunshine's global_prep_cmd, so it runs for every app:
# start -> hold a systemd idle inhibitor (hypridle respects these) and
# unlock hyprlock if the session was already locked
# stop -> release the inhibitor so normal idle locking resumes
pidfile="${XDG_RUNTIME_DIR:-/tmp}/sunshine-idle-inhibit.pid"
case "$1" in
start)
if ! { [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; }; then
setsid systemd-inhibit --what=idle --who=Sunshine --why="Streaming" \
sleep infinity >/dev/null 2>&1 &
echo $! > "$pidfile"
fi
# SIGUSR1 tells hyprlock to unlock
pkill -USR1 -x hyprlock
;;
stop)
[ -f "$pidfile" ] && kill "$(cat "$pidfile")" 2>/dev/null
rm -f "$pidfile"
;;
*)
echo "Usage: $0 start|stop" >&2
exit 1
;;
esac
exit 0

View File

@@ -1,143 +1,37 @@
#!/bin/bash
# HyprFlux — https://github.com/ahmad9059/HyprFlux
# Scripts for volume controls for audio and mic
# Volume controls for audio and mic via wpctl (PipeWire).
# Quickshell's VolumeOsd watches PipeWire directly, so no notifications here.
iDIR="$HOME/.config/swaync/icons"
sDIR="$HOME/.config/hypr/scripts"
SINK="@DEFAULT_AUDIO_SINK@"
SOURCE="@DEFAULT_AUDIO_SOURCE@"
STEP="5%"
# Get Volume
# Print volume as an integer percent, or "Muted"
get_volume() {
volume=$(pamixer --get-volume)
if [[ "$volume" -eq "0" ]]; then
local out
out=$(wpctl get-volume "$1")
if [[ "$out" == *MUTED* ]]; then
echo "Muted"
else
echo "$volume %"
awk '{ printf "%d %%\n", $2 * 100 + 0.5 }' <<< "$out"
fi
}
# Get icons
get_icon() {
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
is_muted() {
[[ "$(wpctl get-volume "$1")" == *MUTED* ]]
}
# Notify
notify_user() {
if [[ "$(get_volume)" == "Muted" ]]; then
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
}
# Raising/lowering while muted unmutes first, matching the old behaviour
inc() { is_muted "$1" && wpctl set-mute "$1" 0 || wpctl set-volume -l 1.0 "$1" "$STEP+"; }
dec() { is_muted "$1" && wpctl set-mute "$1" 0 || wpctl set-volume "$1" "$STEP-"; }
# Increase Volume
inc_volume() {
if [ "$(pamixer --get-mute)" == "true" ]; then
toggle_mute
else
pamixer -i 5 --allow-boost --set-limit 150 && notify_user
fi
}
# Decrease Volume
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
case "$1" in
--inc) inc "$SINK" ;;
--dec) dec "$SINK" ;;
--toggle) wpctl set-mute "$SINK" toggle ;;
--toggle-mic) wpctl set-mute "$SOURCE" toggle ;;
--mic-inc) inc "$SOURCE" ;;
--mic-dec) dec "$SOURCE" ;;
--get-mic) get_volume "$SOURCE" ;;
*) get_volume "$SINK" ;;
esac

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 Appearance 1.0 Appearance.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 {}
}
Clock {
RowLayout {
anchors.centerIn: parent
spacing: Appearance.spacing
Weather { barWindow: bar }
Clock {}
SystemUpdate { barWindow: bar }
}
RowLayout {
@@ -46,7 +50,7 @@ PanelWindow {
rightMargin: Appearance.spacing
}
spacing: Appearance.spacing
//Tray {}
PinnedTray {}
TailscaleIndicator { barWindow: bar }
VpnIndicator { barWindow: bar }
NetworkIndicator { barWindow: bar }

View File

@@ -2,6 +2,7 @@ import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import Quickshell.Services.UPower
import "../../config"
@@ -14,7 +15,7 @@ import "../../config"
// Nerd Font ones. The charge-threshold ("Holding"/limited-charge) concept from
// the original is hardware-specific (vendor charge-limit sysfs knobs) and has
// no equivalent in Quickshell's UPower binding, so it's dropped rather than
// guessed at; "Battery health" (device.healthPercentage) is shown in its
// guessed at; "Battery health" (computed from sysfs energy_full/design) is shown in its
// place, using data UPower actually reports.
Item {
id: root
@@ -51,10 +52,162 @@ Item {
readonly property real fraction: root.batteryPresent ? Math.max(0, Math.min(1, root.device.percentage)) : 0
readonly property bool low: root.batteryPresent && root.fraction <= 0.15 && !root.charging
// Quickshell's UPower binding has no charge-cycle property (see the
// file-header comment on why the old charge-until threshold was
// dropped) — it comes straight from sysfs instead.
//
// device.nativePath turned out to be empty on real hardware (tested
// live), so rather than build a path from it, a shell glob finds
// whichever BAT* directory actually exists — same shell-out-to-a-CLI
// convention TailscaleIndicator.qml uses for its own live stats.
// This barely changes during a session, so one read at startup is
// enough; no polling timer.
property int cycleCount: -1
Process {
id: cycleCountProc
command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/cycle_count\" 2>/dev/null"]
running: root.batteryPresent
stdout: StdioCollector {
id: cycleCountCollector
onStreamFinished: {
const n = parseInt(cycleCountCollector.text.trim(), 10)
root.cycleCount = (isNaN(n) || n <= 0) ? -1 : n
}
}
}
// device.healthPercentage/healthSupported come from upowerd over D-Bus,
// and upowerd has been observed reporting energy-full as a flat 0 (a
// live daemon glitch, `upower -i` shows it too) while sysfs still has
// the real numbers underneath — same class of unreliability that put
// cycle_count on sysfs above instead of trusting upowerd's own
// charge-cycles field. Health is computed the same way UPower computes
// it (current full-charge capacity over the battery's original design
// capacity), just read directly so a daemon hiccup doesn't blank it.
property real energyFullWh: -1
property real energyFullDesignWh: -1
readonly property real healthPercent: (root.energyFullWh > 0 && root.energyFullDesignWh > 0)
? Math.min(100, (root.energyFullWh / root.energyFullDesignWh) * 100) : -1
Process {
id: energyFullProc
command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/energy_full\" 2>/dev/null"]
running: root.batteryPresent
stdout: StdioCollector {
id: energyFullCollector
onStreamFinished: {
const n = parseFloat(energyFullCollector.text.trim())
root.energyFullWh = (isNaN(n) || n <= 0) ? -1 : (n / 1000000)
}
}
}
Process {
id: energyFullDesignProc
command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/energy_full_design\" 2>/dev/null"]
running: root.batteryPresent
stdout: StdioCollector {
id: energyFullDesignCollector
onStreamFinished: {
const n = parseFloat(energyFullDesignCollector.text.trim())
root.energyFullDesignWh = (isNaN(n) || n <= 0) ? -1 : (n / 1000000)
}
}
}
// device.timeToFull/timeToEmpty are computed by Quickshell from
// energy/energyCapacity/changeRate — when upowerd's EnergyFull glitches
// to 0 (the same daemon flake HEALTH routes around above), that formula
// divides by a bogus capacity and comes out negative rather than a
// real estimate (confirmed live: timeToFull sat at -3751 while `upower
// -i` itself still reported a normal 26.7 minutes). This recomputes
// the same estimate straight from sysfs as a fallback, only used when
// Quickshell's own value isn't positive. Unlike cycle_count/design
// capacity above, energy_now and power_now change continuously while
// (dis)charging, so this polls on a timer instead of reading once.
property real sysfsEnergyNowWh: -1
property real sysfsPowerNowW: -1
readonly property real fallbackTimeToFullSeconds: (root.sysfsPowerNowW > 0 && root.energyFullWh > 0 && root.sysfsEnergyNowWh >= 0)
? Math.max(0, (root.energyFullWh - root.sysfsEnergyNowWh) / root.sysfsPowerNowW * 3600) : -1
readonly property real fallbackTimeToEmptySeconds: (root.sysfsPowerNowW > 0 && root.sysfsEnergyNowWh > 0)
? (root.sysfsEnergyNowWh / root.sysfsPowerNowW * 3600) : -1
// Prefers upowerd's own live estimate when it's sane; only falls back
// to the sysfs-computed one when upowerd's has gone zero/negative.
readonly property real timeRemainingSeconds: {
const primary = root.onBattery ? root.device.timeToEmpty : root.device.timeToFull
if (primary > 0) return primary
return root.onBattery ? root.fallbackTimeToEmptySeconds : root.fallbackTimeToFullSeconds
}
Timer {
id: sysfsRateTimer
interval: 15000
repeat: true
running: root.batteryPresent
triggeredOnStart: true
onTriggered: {
if (!sysfsEnergyNowProc.running) sysfsEnergyNowProc.running = true
if (!sysfsPowerNowProc.running) sysfsPowerNowProc.running = true
}
}
Process {
id: sysfsEnergyNowProc
command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/energy_now\" 2>/dev/null"]
stdout: StdioCollector {
id: sysfsEnergyNowCollector
onStreamFinished: {
const n = parseFloat(sysfsEnergyNowCollector.text.trim())
root.sysfsEnergyNowWh = (isNaN(n) || n < 0) ? -1 : (n / 1000000)
}
}
}
Process {
id: sysfsPowerNowProc
command: ["sh", "-c", "cat \"$(ls -d /sys/class/power_supply/BAT* 2>/dev/null | head -n1)/power_now\" 2>/dev/null"]
stdout: StdioCollector {
id: sysfsPowerNowCollector
onStreamFinished: {
const n = parseFloat(sysfsPowerNowCollector.text.trim())
root.sysfsPowerNowW = (isNaN(n) || n <= 0) ? -1 : (n / 1000000)
}
}
}
// Raw UPower state string (Charging/Discharging/PendingCharge/…) — more
// granular than modeLabel below, which collapses those down to the
// three phrases the hero text and STATE row show.
readonly property string rawStateLabel: root.batteryPresent
? UPowerDeviceState.toString(root.deviceState) : "—"
// Material Symbols' charging glyphs aren't an evenly-spaced "_bar"
// scale like the non-charging ones below — they're fixed at these
// seven breakpoints (confirmed against the installed font's ligature
// table), so this picks the highest one at or below the current
// percentage rather than rounding to an index.
function chargingIcon(fraction) {
const percent = fraction * 100
const thresholds = [20, 30, 50, 60, 80, 90, 100]
const icons = [
"battery_charging_20", "battery_charging_30", "battery_charging_50", "battery_charging_60",
"battery_charging_80", "battery_charging_90", "battery_charging_full"
]
for (let i = thresholds.length - 1; i >= 0; i--) {
if (percent >= thresholds[i]) return icons[i]
}
// Below 20% there's no lower charging glyph — it's still the
// truthful floor of what's available while current is flowing in.
return icons[0]
}
function batteryIcon() {
if (!root.batteryPresent) return "battery_unknown"
if (root.fullyCharged) return "battery_full"
if (root.charging) return "battery_charging_full"
if (root.charging) return root.chargingIcon(root.fraction)
if (root.fraction <= 0.1) return "battery_alert"
const bars = ["battery_0_bar", "battery_1_bar", "battery_2_bar", "battery_3_bar", "battery_4_bar", "battery_5_bar", "battery_full"]
return bars[Math.max(0, Math.min(bars.length - 1, Math.round(root.fraction * (bars.length - 1))))]
@@ -74,6 +227,32 @@ Item {
return root.onBattery ? "On battery" : "Charging"
}
// Info grid rows — same shape as NetworkIndicator.qml's statRows/
// statCells: a flat label/value list, flattened again into individual
// grid cells (label, value, label, value, …) so a 4-column GridLayout
// lays them out as two label/value pairs per row.
readonly property var statRows: [
{
label: "HEALTH",
value: root.healthPercent > 0 ? Math.round(root.healthPercent) + "%" : "—"
},
{
label: root.onBattery ? "TIME LEFT" : "TIME TO FULL",
value: root.batteryPresent ? root.formatDuration(root.timeRemainingSeconds) : "—"
},
{ label: "STATUS", value: root.rawStateLabel },
{ label: "CYCLES", value: root.cycleCount > 0 ? String(root.cycleCount) : "—" }
]
readonly property var statCells: {
const cells = []
for (let i = 0; i < root.statRows.length; i++) {
cells.push({ text: root.statRows[i].label, isLabel: true })
cells.push({ text: root.statRows[i].value, isLabel: false })
}
return cells
}
// Cute rotating status phrases while current is actually flowing —
// ported as-is from omarchy's power panel, which pairs the same phrase
// sets with the same charging/on-battery states.
@@ -142,6 +321,34 @@ Item {
return entries
}
// --- Automatic power profile --------------------------------------
// Battery-state-driven, on top of the manual picker above: below 20%
// → Power Saver (protect what's left), plugged in → Performance,
// unplugged and healthy → Balanced. Low battery wins even if a weak
// charger is still plugged in — running low is the more urgent signal.
// Performance only gets requested when the daemon actually reports it
// as available, falling back to Balanced instead of erroring on
// hardware without a performance profile (e.g. the ERROR this exact
// widget hit before power-profiles-daemon was installed).
//
// This only ever writes when the *target* changes (crossing the 20%
// line, plugging/unplugging) rather than on every poll, so a manual
// pick from the buttons above still sticks until the next real
// transition instead of being fought immediately.
readonly property int desiredProfile: {
if (root.fraction <= 0.20) return PowerProfile.PowerSaver
if (!root.onBattery) return PowerProfiles.hasPerformanceProfile ? PowerProfile.Performance : PowerProfile.Balanced
return PowerProfile.Balanced
}
function applyDesiredProfile() {
if (!root.batteryPresent) return
if (PowerProfiles.profile !== root.desiredProfile) PowerProfiles.profile = root.desiredProfile
}
onDesiredProfileChanged: root.applyDesiredProfile()
Component.onCompleted: Qt.callLater(root.applyDesiredProfile)
Text {
id: label
anchors.centerIn: parent
@@ -217,33 +424,16 @@ Item {
}
}
component StatRow: RowLayout {
id: statRow
width: parent.width
property string label: ""
property string value: ""
Text {
Layout.fillWidth: true
text: statRow.label
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
Text {
text: statRow.value
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
}
// --- Panel -----------------------------------------------------------
PopupWindow {
id: panel
visible: false
color: "transparent"
readonly property int contentWidth: 244
// Wider than before (244) to give the 2-column info grid's
// label/value pairs enough room per half without eliding —
// same reasoning as NetworkIndicator.qml's own wider panel.
readonly property int contentWidth: 280
readonly property int contentPadding: 16
implicitWidth: contentWidth + contentPadding * 2
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
@@ -350,34 +540,27 @@ Item {
}
}
// Stats
RowLayout {
// Stats — a 2-column, 2-row info grid, same layout
// NetworkIndicator.qml uses for its own live stats: a
// 4-column GridLayout fed a flattened label/value/label/value
// list, so each row holds two label/value pairs side by side
// with the values right-aligned against their own half.
GridLayout {
width: parent.width
spacing: 16
columns: 4
columnSpacing: 12
rowSpacing: 5
Column {
Layout.fillWidth: true
spacing: 4
StatRow { label: "STATE"; value: root.modeLabel }
StatRow {
label: "HEALTH"
value: (root.batteryPresent && root.device.healthSupported)
? Math.round(root.device.healthPercentage) + "%" : "—"
}
}
Column {
Layout.fillWidth: true
spacing: 4
StatRow {
label: root.onBattery ? "TIME LEFT" : "TIME TO FULL"
value: root.batteryPresent
? root.formatDuration(root.onBattery ? root.device.timeToEmpty : root.device.timeToFull)
: "—"
}
StatRow {
label: "RATE"
value: root.batteryPresent ? Math.abs(root.device.changeRate).toFixed(1) + " W" : "—"
Repeater {
model: root.statCells
Text {
required property var modelData
Layout.fillWidth: !modelData.isLabel
horizontalAlignment: modelData.isLabel ? Text.AlignLeft : Text.AlignRight
text: modelData.text
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: modelData.isLabel ? Colors.muted : Colors.foreground
}
}
}

View File

@@ -222,6 +222,82 @@ Item {
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 --------------------------------------------------
// None of this comes from Quickshell's Networking API — it only knows
// 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 {
anchors.fill: parent
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
// device is currently connected.
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.tailnet: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"
RowLayout {
spacing: Appearance.spacing * 0.6
spacing: Appearance.spacing
Repeater {
model: SystemTray.items

View File

@@ -45,9 +45,41 @@ Item {
// to connect once the current one has finished disconnecting.
property string pendingConnect: ""
// Surfaced under the header for a failed connect/disconnect; cleared
// automatically after a few seconds.
property string actionStatus: ""
// Surfaced under each provider's own row for a failed connect/
// disconnect/settings change; cleared automatically after a few
// 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) {
const line = String(text || "").trim().split("\n")[0] || ""
@@ -82,6 +114,95 @@ Item {
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() {
if (!root.mullvadInstalled) {
if (!mullvadWhichProc.running) mullvadWhichProc.running = true
@@ -197,8 +318,8 @@ Item {
stderr: StdioCollector { id: mullvadConnectErr }
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
root.actionStatus = "Mullvad: " + root.shortLine(mullvadConnectErr.text || "connect failed")
actionStatusTimer.restart()
root.mullvadActionStatus = root.shortLine(mullvadConnectErr.text || "Connect failed")
mullvadActionStatusTimer.restart()
} else if (!tailscaleDownProc.running) {
tailscaleDownProc.running = true
}
@@ -213,8 +334,8 @@ Item {
stderr: StdioCollector { id: mullvadDisconnectErr }
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
root.actionStatus = "Mullvad: " + root.shortLine(mullvadDisconnectErr.text || "disconnect failed")
actionStatusTimer.restart()
root.mullvadActionStatus = root.shortLine(mullvadDisconnectErr.text || "Disconnect failed")
mullvadActionStatusTimer.restart()
}
root.refreshMullvad()
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() }
@@ -229,6 +350,77 @@ Item {
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 ---------------------------------------------------------
Process {
@@ -261,8 +453,8 @@ Item {
stderr: StdioCollector { id: protonConnectErr }
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
root.actionStatus = "ProtonVPN: " + root.shortLine(protonConnectErr.text || "connect failed")
actionStatusTimer.restart()
root.protonActionStatus = root.shortLine(protonConnectErr.text || "Connect failed")
protonActionStatusTimer.restart()
} else if (!tailscaleDownProc.running) {
tailscaleDownProc.running = true
}
@@ -277,8 +469,8 @@ Item {
stderr: StdioCollector { id: protonDisconnectErr }
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
root.actionStatus = "ProtonVPN: " + root.shortLine(protonDisconnectErr.text || "disconnect failed")
actionStatusTimer.restart()
root.protonActionStatus = root.shortLine(protonDisconnectErr.text || "Disconnect failed")
protonActionStatusTimer.restart()
}
root.refreshProton()
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() }
@@ -305,10 +497,17 @@ Item {
}
Timer {
id: actionStatusTimer
id: mullvadActionStatusTimer
interval: 4000
repeat: false
onTriggered: root.actionStatus = ""
onTriggered: root.mullvadActionStatus = ""
}
Timer {
id: protonActionStatusTimer
interval: 4000
repeat: false
onTriggered: root.protonActionStatus = ""
}
// --- Bar icon ------------------------------------------------------------
@@ -416,7 +615,13 @@ Item {
property string detailText: ""
property string state: "disconnected"
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 expandToggle()
width: parent ? parent.width : 0
radius: 0
implicitHeight: rowContent.implicitHeight + 12
@@ -477,9 +682,91 @@ Item {
busy: provRow.state === "connecting" || provRow.state === "disconnecting"
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 -----------------------------------------------------------
PopupWindow {
@@ -494,7 +781,18 @@ Item {
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 {
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 {
width: parent.width
spacing: 2
ProviderRow {
label: "Mullvad"
subIcon: "gpp_good"
state: root.mullvadState
detailText: root.mullvadDetail
installed: root.mullvadInstalled
onToggle: root.toggleMullvad()
// Mullvad's connect row and its advanced options share a
// single bordered card — the border wraps the whole
// section (row, status line, and options together) and
// grows to include the options/picker as they open,
// rather than the options having a separate box of
// their own below an unbordered connect row.
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 {
label: "ProtonVPN"
subIcon: "gpp_good"
@@ -578,6 +1050,8 @@ Item {
installed: root.protonInstalled
onToggle: root.toggleProton()
}
StatusLine { message: root.protonActionStatus }
}
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"
exclusiveZone: 0
// Top-right, just under the bar — same fixed distance from the bar and
// screen edge (Appearance.spacing) that every popup opened from the bar
// itself uses, so this lines up with them instead of floating
// bottom-center.
anchors { top: true; right: true }
margins.top: Appearance.spacing
margins.right: Appearance.spacing
// Bottom-center: anchoring only the bottom edge lets the layer shell
// center it horizontally.
anchors { bottom: true }
margins.bottom: Appearance.spacing * 4
implicitWidth: 272
implicitHeight: 54
implicitWidth: 300
implicitHeight: 60
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "quickshell:osd"
@@ -49,13 +46,13 @@ PanelWindow {
RowLayout {
anchors.fill: parent
anchors.margins: 14
spacing: 12
anchors.margins: 15
spacing: 13
Text {
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 18
font.pixelSize: 21
color: Colors.foreground
text: {
const audio = osd.sink ? osd.sink.audio : null
@@ -67,7 +64,7 @@ PanelWindow {
Item {
id: slider
Layout.fillWidth: true
implicitHeight: 16
implicitHeight: 18
readonly property real value: osd.sink && osd.sink.audio ? osd.sink.audio.volume : 0
readonly property bool enabled: !!osd.sink
@@ -76,7 +73,7 @@ PanelWindow {
id: track
anchors.verticalCenter: parent.verticalCenter
width: parent.width
height: 4
height: 5
radius: 0
color: Colors.muted
opacity: 0.35
@@ -85,15 +82,15 @@ PanelWindow {
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
width: track.width * Math.max(0, Math.min(1, slider.value))
height: 4
height: 5
radius: 0
color: Colors.primary
opacity: slider.enabled ? 1 : 0.4
}
Rectangle {
width: 12
height: 12
radius: 6
width: 14
height: 14
radius: 7
color: Colors.primary
opacity: slider.enabled ? 1 : 0.4
anchors.verticalCenter: parent.verticalCenter
@@ -113,10 +110,10 @@ PanelWindow {
}
Text {
Layout.preferredWidth: 32
Layout.preferredWidth: 38
horizontalAlignment: Text.AlignRight
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
font.pixelSize: 13
color: Colors.muted
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 {
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 {
required property var modelData

5
dotfiles/.config/sunshine/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
# Machine-local Sunshine state lives next to the stowed config; never commit it
credentials/
*.log
*.log.*
sunshine_state.json

View File

@@ -0,0 +1,47 @@
{
"env": {
"PATH": "$(PATH):$(HOME)/.local/bin"
},
"apps": [
{
"name": "Desktop",
"image-path": "desktop.png"
},
{
"name": "Low Res Desktop",
"image-path": "desktop.png",
"prep-cmd": [
{
"do": "xrandr --output HDMI-1 --mode 1920x1080",
"undo": "xrandr --output HDMI-1 --mode 1920x1200"
}
]
},
{
"name": "Steam Big Picture",
"detached": [
"setsid steam steam://open/bigpicture"
],
"prep-cmd": [
{
"do": "/home/thomas/.config/sunshine/scripts/steam-bigpicture-prep.sh",
"undo": "setsid steam steam://close/bigpicture"
}
],
"image-path": "steam.png"
},
{
"name": "Steam Big Picture (TV)",
"detached": [
"setsid steam steam://open/bigpicture"
],
"prep-cmd": [
{
"do": "/home/thomas/.config/sunshine/scripts/steam-bigpicture-tv-prep.sh",
"undo": "/home/thomas/.config/sunshine/scripts/steam-bigpicture-tv-undo.sh"
}
],
"image-path": "steam.png"
}
]
}

View File

@@ -0,0 +1,17 @@
#!/bin/sh
# Run before Sunshine launches Steam Big Picture: focus the headless virtual
# monitor and park the cursor on it, so Big Picture (which spawns on whatever
# monitor currently has focus/the mouse) opens there instead of wherever the
# mouse happened to be.
set -e
hyprctl dispatch 'hl.dsp.focus({monitor = "HEADLESS-1"})'
hyprctl dispatch 'hl.dsp.cursor.move({x = 5344, y = 540})'
# Sunshine only flips the system default audio sink to sink-sunshine-stereo
# after the streaming session/client handshake completes (~500ms after this
# script runs and Steam is spawned) — see sunshine.log timestamps. If Steam's
# own audio (SDL) initializes in that window, it binds to whatever was
# default at the time (the real HDMI output) and never re-homes even after
# Sunshine's swap. Set it here first so it's already correct before Steam
# ever launches; Sunshine's own (redundant) swap right after is harmless.
pactl set-default-sink sink-sunshine-stereo || true

View File

@@ -0,0 +1,18 @@
#!/bin/sh
# Run before Sunshine launches Steam Big Picture for the 4K TV target.
# Switches the headless virtual monitor from its default 1728x1080@60
# (used for the Steam Deck target) up to 3840x2160@60, then focuses it
# and parks the cursor there so Big Picture opens on it. Reverted by
# steam-bigpicture-tv-undo.sh once the stream ends.
set -e
# NOTE: this Hyprland build uses the Lua config layer, which rejects the
# usual `hyprctl keyword monitor ...` syntax ("keyword can't work with
# non-legacy parsers. Use eval.") — hl.monitor() via `hyprctl eval` is the
# runtime equivalent of the hl.monitor() call in layout/monitors.lua.
hyprctl eval 'hl.monitor({ output = "HEADLESS-1", mode = "3840x2160@60.00000", position = "4480x0", scale = 1.00000000, transform = 0, vrr = 0 })'
hyprctl dispatch 'hl.dsp.focus({monitor = "HEADLESS-1"})'
hyprctl dispatch 'hl.dsp.cursor.move({x = 6400, y = 1080})'
# See steam-bigpicture-prep.sh for why this runs here instead of relying on
# Sunshine's own post-handshake audio-sink swap.
pactl set-default-sink sink-sunshine-stereo || true

View File

@@ -0,0 +1,6 @@
#!/bin/sh
# Undo for steam-bigpicture-tv-prep.sh: close Big Picture and restore
# HEADLESS-1 to its default mode (the one the Steam Deck target expects).
set -e
setsid steam steam://close/bigpicture
hyprctl eval 'hl.monitor({ output = "HEADLESS-1", mode = "1728x1080@60.00000", position = "4480x0", scale = 1.00000000, transform = 0, vrr = 0 })'

View File

@@ -0,0 +1,5 @@
audio_sink = sink-sunshine-stereo
csrf_allowed_origins = https://xol.twhomelab.local:47990
nvenc_preset = 4
output_name = HEADLESS-1
global_prep_cmd = [{"do":"/home/thomas/.config/hypr/scripts/SunshineIdle.sh start","undo":"/home/thomas/.config/hypr/scripts/SunshineIdle.sh stop"}]

View File

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

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

@@ -0,0 +1,18 @@
#!/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.
# QT_QPA_PLATFORM: hypr env sets "wayland;xcb"; under Wayland the bundled
# QtWebEngine never runs the web client and the window stays blank.
unset QT_QUICK_CONTROLS_STYLE
export QT_QPA_PLATFORM=xcb
# Unset in the session; without it the X11 cursor in Plex is huge.
export XCURSOR_SIZE=24
exec /opt/plex-desktop/Plex.sh "$@"

View File

@@ -0,0 +1,31 @@
# 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).
#
# QT_QPA_PLATFORM=xcb is kept from upstream: hypr env sets "wayland;xcb", and
# under Wayland the bundled QtWebEngine never runs the web client (blank
# window, no [Web] lines in Plex.log). XCURSOR_SIZE=24 is also kept from
# upstream: it is unset in the session, so the X11 cursor comes out huge.
#
# 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 QT_QPA_PLATFORM=xcb XCURSOR_SIZE=24 /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
alias cl='clear'
alias cl='clear && fastfetch'
alias zed='zeditor'
alias ssh-gavin='ssh thoams@192.168.1.83'
alias ssh-raspberry-pi='ssh thoams@192.168.1.86'
@@ -51,3 +51,4 @@ export ZED_ALLOW_EMULATED_GPU=1
fastfetch
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

197
system/packages.txt Normal file
View File

@@ -0,0 +1,197 @@
# ============================================================================
# Packages installed by bootstrap.sh on a new machine.
#
# - One package per line; anything after "#" is a description.
# - Lines starting with "#" are skipped (left here for reference).
# - [section] lines tell bootstrap.sh which installer to use;
# [services] lists systemd units to enable.
#
# Not listed here (bootstrap.sh handles them itself):
# - CPU microcode and GPU drivers: auto-detected from the hardware
# - yay: bootstrapped from the AUR before the [aur] section
# - svt-av1-essential-git: optional, also rebuilds ffmpeg against it
# ============================================================================
[pacman]
adw-gtk-theme # Unofficial GTK 3 port of the libadwaita theme
# amd-ucode # Microcode update image for AMD CPUs -- auto-detected by bootstrap.sh
awww # An Answer to your Wayland Wallpaper Woes
baobab # A graphical directory tree analyzer
base # Minimal package set to define a basic Arch Linux installation
base-devel # Basic tools to build Arch Linux packages
blueman # GTK+ Bluetooth Manager
bluez # Daemons for the bluetooth protocol stack
bluez-utils # Development and debugging utilities for the bluetooth protocol stack
btop # A monitor of system resources, bpytop ported to C++
dolphin # KDE File Manager
# dunst # Customizable and lightweight notification-daemon !! you use swaync for notifications
efibootmgr # Linux user-space application to modify the EFI Boot Manager
fastfetch # A feature-rich and performance oriented neofetch like system information tool
ffmpeg # Complete solution to record, convert and stream audio and video
filezilla # Fast and reliable FTP, FTPS and SFTP client
firefox # Fast, Private & Safe Web Browser
flatpak # Linux application sandboxing and distribution framework (formerly xdg-app)
foot # Fast, lightweight, and minimalistic Wayland terminal emulator
fzf # Command-line fuzzy finder
galculator # GTK based scientific calculator
# garcon # Freedesktop.org compliant menu library !! XFCE desktop leftover?
gdu # Fast disk usage analyzer
git # the fast distributed version control system
gnome-calendar # Simple and beautiful calendar application designed to perfectly fit the GNOME desktop
gnome-disk-utility # Disk Management Utility for GNOME
gnome-font-viewer # A font viewer utility for GNOME
gnome-keyring # Stores passwords and encryption keys
gnome-text-editor # A simple text editor for the GNOME desktop
grim # Screenshot utility for Wayland
gst-plugin-pipewire # Multimedia graph framework - pipewire plugin
gvfs # Virtual filesystem implementation for GIO
gvfs-smb # Virtual filesystem implementation for GIO - SMB/CIFS backend (Windows file sharing)
htop # Interactive process viewer
hypridle # hyprland’s idle daemon
hyprland # a highly customizable dynamic tiling Wayland compositor
hyprlock # hyprland’s GPU-accelerated screen locking utility
hyprshot # Hyprland screenshot utility
imv # Image viewer for Wayland and X11
jq # Command-line JSON processor
# kitty # A modern, hackable, featureful, OpenGL-based terminal emulator !! you use foot as terminal (kitty config not in dotfiles)
libgsf # Extensible I/O abstraction library for dealing with structured file formats
libmatroska # Matroska library
libpulse # A featureful, general-purpose sound server (client library)
# libva-nvidia-driver # VA-API implementation that uses NVDEC as a backend -- auto-detected by bootstrap.sh
linux # The Linux kernel and modules
linux-firmware # Firmware files for Linux - Default set
mkinitcpio # Modular initramfs image creation utility
mkvtoolnix-cli # Set of tools to create, edit and inspect Matroska files
mpv # a free, open source, and cross-platform media player
mullvad-vpn-daemon # Mullvad VPN client (daemon and CLI)
nano # Pico editor clone with enhancements
network-manager-applet # Applet for managing network connections
networkmanager # Network connection manager and user applications
noto-fonts # Google Noto TTF fonts
noto-fonts-cjk # Google Noto CJK fonts
noto-fonts-emoji # Google Noto Color Emoji font
noto-fonts-extra # Google Noto TTF fonts - additional variants
npm # JavaScript package manager
nss-mdns # glibc plugin providing host name resolution via mDNS
# nvidia-open # NVIDIA open kernel modules -- auto-detected by bootstrap.sh
pacman-contrib # Contributed scripts and tools for pacman systems
pam-u2f # Universal 2nd Factor (U2F) PAM authentication module from Yubico
papirus-icon-theme # Papirus icon theme
pipewire # Low-latency audio/video router and processor
pipewire-alsa # Low-latency audio/video router and processor - ALSA configuration
pipewire-jack # Low-latency audio/video router and processor - JACK replacement
pipewire-pulse # Low-latency audio/video router and processor - PulseAudio replacement
pnpm # Fast, disk space efficient package manager
polkit-kde-agent # Daemon providing a polkit authentication UI for KDE
postgresql # Sophisticated object-relational DBMS
postgresql-libs # Sophisticated object-relational DBMS - Client binaries and libraries
proton-vpn-cli # Official ProtonVPN CLI Linux app
qt5-wayland # Provides APIs for Wayland
qt6-quickeffectmaker # Qt Quick Effect Maker (QQEM) for creating and editing custom shader effects
qt6-wayland # Provides APIs for Wayland
rofi # A window switcher, application launcher and dmenu replacement
sddm # QML based X11 and Wayland display manager
slurp # Select a region in a Wayland compositor
smartmontools # Control and monitor S.M.A.R.T. enabled ATA and SCSI Hard Drives
steam # Valve's digital software delivery system
stow # Manage installation of multiple softwares in the same directory tree
sudo # Give certain users the ability to run some commands as root
swaync # A simple GTK based notification daemon for Sway
tailscale # A mesh VPN that makes it easy to connect your devices, wherever they are.
thunar # Modern, fast and easy-to-use file manager for Xfce
thunar-archive-plugin # Adds archive operations to the Thunar file context menus
thunar-media-tags-plugin # Adds special features for media files to the Thunar File Manager
thunar-shares-plugin # Thunar plugin to quickly share a folder using Samba without requiring root access
thunar-volman # Automatic management of removable drives and media for Thunar
ttf-dejavu # Font family based on the Bitstream Vera Fonts with a wider range of characters
ttf-iosevka-nerd # Patched font Iosevka from nerd fonts library
ttf-jetbrains-mono # Typeface for developers, by JetBrains
ttf-jetbrains-mono-nerd # Patched font JetBrains Mono from nerd fonts library
ttf-liberation # Font family which aims at metric compatibility with Arial, Times New Roman, and Courier New
ttf-material-icons # Material Design icons by Google (deprecated classic Material Icons static style)
ttf-material-symbols-variable # Material Design icons by Google (Material Symbols - 4 axis variable)
ttf-noto-nerd # Patched font Noto from nerd fonts library
tumbler # Thumbnail service implementing the thumbnail management D-Bus specification
uv # An extremely fast Python package installer and resolver written in Rust
uwsm # A standalone Wayland session manager
vim # Vi Improved, a highly configurable, improved version of the vi text editor
# waybar # Highly customizable Wayland bar for Sway and Wlroots based compositors !! you've switched to quickshell (waybar is commented out in startup.lua)
webp-pixbuf-loader # WebM GDK Pixbuf Loader library
wget # Network utility to retrieve files from the web
wireplumber # Session / policy manager implementation for PipeWire
# wofi # launcher for wlroots-based wayland compositors !! you also have rofi — need both?
wpa_supplicant # A utility providing key negotiation for WPA wireless networks
xarchiver # GTK frontend to various command line archivers
xdg-desktop-portal-hyprland # xdg-desktop-portal backend for hyprland
xdg-utils # Command line tools that assist applications with a variety of desktop integration tasks
# xfce4-appfinder # Application launcher and finder !! XFCE desktop leftover?
# xfce4-panel # Panel for the Xfce desktop environment !! XFCE desktop leftover?
# xfce4-power-manager # Power Manager for Xfce !! XFCE desktop leftover?
# xfce4-session # Xfce's session manager !! XFCE desktop leftover?
# xfce4-settings # Xfce's Configuration System !! XFCE desktop leftover?
# xfce4-terminal # A modern terminal emulator primarily for the Xfce desktop environment !! XFCE desktop leftover?
# xfdesktop # Xfce's desktop manager !! XFCE desktop leftover?
# xfwm4 # Xfce's window manager !! XFCE desktop leftover?
zram-generator # Systemd unit generator for zram devices
zsh # A very advanced and programmable command interpreter (shell) for UNIX
# Used by the dotfiles, previously only pulled in by caelestia-shell / waybar
brightnessctl # Backlight control (quickshell, hypr keybinds)
cliphist # Clipboard history (wl-paste watcher in startup.lua)
playerctl # Media key control
power-profiles-daemon # Power profiles (quickshell battery widget)
wl-clipboard # wl-copy / wl-paste
openssh # SSH client/server (sshd service, git over ssh)
avahi # mDNS daemon (avahi-daemon service, used by nss-mdns)
[aur]
bitwarden-bin # A secure and free password manager for all of your devices.
# caelestia-shell # The desktop shell for the Caelestia dotfiles !! alternative quickshell config, not used by your shell.qml
flutter-bin # Cross platform widget toolkit for Dart (monolithic)
google-chrome # The popular web browser by Google (Stable Channel)
hyprmod # Native GTK4/libadwaita settings app for Hyprland
# jellyfin-desktop-debug # Detached debugging symbols for jellyfin-desktop !! debug symbols, not needed
minecraft-launcher # Official Minecraft Launcher
# orbit-wifi # A WiFi/Bluetooth manager for Wayland with glassmorphism UI !! removed from your config in commit 88484f8
plex-desktop # Plex desktop client for linux
plexamp-bin # Modern music client for Plex.(Prebuilt version.Use system-wide electron)
puddletag # An audio tag editor for GNU/Linux
quickshell-git # The shell your bar/OSD run on (previously a caelestia-shell dependency)
pwvucontrol # Pipewire volume control for GNOME
python-pywal # Generate and change colorschemes on the fly
raw-thumbnailer # A lightweight and fast raw image thumbnailer that can be used by file managers.
seafile-client # GUI client for synchronizing your local files with seafile server
sunshine # Self-hosted game stream host for Moonlight
# svt-av1-essential-git # A no-nonsense SVT-AV1 encoder fork with sensible defaults and Quality of Life improvements -- optional step in bootstrap.sh (rebuilds ffmpeg)
ttf-icomoon-feather # Icomoon feather font
tumbler-extra-thumbnailers # Customized thumbnailers for Tumbler
turritopsis # Desktop Jellyfin client with mpv playback
vesktop # A standalone Electron-based Discord app with Vencord & improved Linux support
visual-studio-code-bin # Visual Studio Code (vscode)
waypaper # GUI wallpaper setter for Wayland and Xorg window managers. It works as a frontend for popular wallpaper backends like swaybg, swww, wallutils, hyprpaper and feh.
# yay # Yet another yogurt. Pacman wrapper and AUR helper written in go. !! bootstrapped by the script itself anyway
# yay-debug # Detached debugging symbols for yay !! debug symbols, not needed
zen-browser-bin # Official package for Zen, a privacy-focused, feature packed Firefox-based web browser
[flatpak]
com.makemkv.MakeMKV # MakeMKV
org.jellyfin.JellyfinDesktop # Jellyfin Desktop
[installer] # official install scripts, see bootstrap.sh
claude # Claude Code CLI (official install script)
oh-my-posh # Prompt theme engine (official install script; your theme is already in dotfiles)
# ffmpeg # Static ffmpeg build — !! duplicate, ffmpeg is also installed via pacman above
# python3.13 # Standalone Python 3.13 — !! probably not needed, uv can provide Python
[services] # enabled with systemctl, start on next boot
NetworkManager.service # Networking
bluetooth.service # Bluetooth
sddm.service # Login screen
sshd.service # SSH server
avahi-daemon.service # mDNS / .local hostnames
systemd-resolved.service # DNS resolver (used by the VPNs)
systemd-timesyncd.service # Clock sync
fstrim.timer # Weekly SSD trim
tailscaled.service # Tailscale
mullvad-daemon.service # Mullvad VPN
proton.VPN.service # Proton VPN

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

27
system/rebuild-ffmpeg.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Rebuilds Arch's ffmpeg package from source so it links against the installed
# svt-av1-essential-git instead of the repo svt-av1 it was built with.
#
# Re-run this after a `pacman -Syu` that updates ffmpeg, otherwise the repo
# build replaces this one.
set -euo pipefail
if ! pacman -Qq svt-av1-essential-git >/dev/null 2>&1; then
echo "svt-av1-essential-git is not installed; nothing to rebuild against." >&2
exit 1
fi
build="$(mktemp -d)"
trap 'rm -rf "$build"' EXIT
git clone --depth 1 https://gitlab.archlinux.org/archlinux/packaging/packages/ffmpeg.git "$build/ffmpeg"
cd "$build/ffmpeg"
# The PKGBUILD verifies a signed ffmpeg tag; makepkg won't fetch the keys itself
mapfile -t keys < <(source ./PKGBUILD && printf '%s\n' "${validpgpkeys[@]}")
if (( ${#keys[@]} )); then
gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys "${keys[@]}"
fi
# No --needed: the version matches what's installed, we want it replaced anyway
makepkg --syncdeps --install --cleanbuild --noconfirm