update dotfiles

This commit is contained in:
David Aizenberg
2025-11-26 23:17:33 +01:00
parent 6814e099ad
commit 80e865f496
46 changed files with 1549 additions and 1 deletions

57
.chezmoi.toml.tmpl Normal file
View File

@@ -0,0 +1,57 @@
{{- /* Detect hostname and suggest default profile */ -}}
{{- $hostname := .chezmoi.hostname -}}
{{- $defaultProfile := "desktop" -}}
{{- if eq $hostname "box" -}}
{{- $defaultProfile = "desktop" -}}
{{- else if eq $hostname "bluefin" -}}
{{- $defaultProfile = "laptop" -}}
{{- end -}}
{{- /* Prompt for device profile */ -}}
{{- $deviceProfile := promptStringOnce . "deviceProfile" (printf "Device profile (desktop/laptop) [%s]" $defaultProfile) -}}
{{- if eq $deviceProfile "" -}}
{{- $deviceProfile = $defaultProfile -}}
{{- end -}}
{{- /* Detect distro */ -}}
{{- $distro := "unknown" -}}
{{- if stat "/etc/arch-release" -}}
{{- $distro = "arch" -}}
{{- else if lookPath "rpm-ostree" -}}
{{- $distro = "fedora-atomic" -}}
{{- else if stat "/etc/fedora-release" -}}
{{- $distro = "fedora" -}}
{{- end -}}
{{- /* Prompt for secrets path */ -}}
{{- $secretsPath := promptStringOnce . "secretsPath" "Path to secrets directory [~/secrets]" -}}
{{- if eq $secretsPath "" -}}
{{- $secretsPath = "~/secrets" -}}
{{- end -}}
[data]
deviceProfile = {{ $deviceProfile | quote }}
hostname = {{ $hostname | quote }}
distro = {{ $distro | quote }}
secretsPath = {{ $secretsPath | quote }}
# Device-specific configuration
{{- if eq $deviceProfile "desktop" }}
primaryMonitor = "DP-1"
secondaryMonitor = "DP-2"
primaryResolution = "2560x1440@165"
secondaryResolution = "2560x1440@60"
hasMultipleMonitors = true
hasTouchpad = false
hasBattery = false
idleTimeout = 300
{{- else if eq $deviceProfile "laptop" }}
primaryMonitor = "eDP-1"
secondaryMonitor = ""
primaryResolution = "preferred"
secondaryResolution = ""
hasMultipleMonitors = false
hasTouchpad = true
hasBattery = true
idleTimeout = 180
{{- end }}

20
.chezmoiignore Normal file
View File

@@ -0,0 +1,20 @@
# Ignore README and install script in home directory
README.md
install.sh
# Ignore backup files
*.pre-ai
*.bak
*.orig
*.backup
# Ignore git and editor files
.git/
.gitignore
*.swp
*~
.claude/
# OS-specific ignores
.DS_Store
Thumbs.db

View File

@@ -1,3 +1,8 @@
# dotfiles
init script tba
Dotfiles managed with [chezmoi](https://chezmoi.io/). Supports CachyOS (desktop) and Bluefin (laptop) with device-specific configs for Hyprland, Waybar, and related tools. Templates handle monitor setup, touchpad/gestures, battery modules, and idle behavior automatically based on hostname detection.
```bash
# Install
./install.sh
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

View File

@@ -0,0 +1,27 @@
# Autostart applications
# Device: {{ .deviceProfile }}
# Polkit agent (GUI password prompts)
exec-once = /usr/lib/polkit-gnome/polkit-gnome-authentication-agent-1
# System tray & bar
exec-once = nm-applet &
exec-once = waybar
# Notifications
exec-once = swaync
# Wallpaper & idle
exec-once = hyprpaper
exec-once = hypridle
# Clipboard
exec-once = copyq --start-server
{{- if eq .deviceProfile "desktop" }}
# Desktop: Hyprland plugins
exec-once = hyprpm reload -n
{{- else }}
# Laptop: Power management
exec-once = power-profiles-daemon || true
{{- end }}

View File

@@ -0,0 +1,18 @@
$background = rgb(150E11)
$foreground = rgb(FEEFF0)
$color0 = rgb(3D373A)
$color1 = rgb(220615)
$color2 = rgb(08432C)
$color3 = rgb(CE2E9E)
$color4 = rgb(0881BC)
$color5 = rgb(C1E177)
$color6 = rgb(FCD0D3)
$color7 = rgb(F5DFE1)
$color8 = rgb(AC9C9D)
$color9 = rgb(220615)
$color10 = rgb(08432C)
$color11 = rgb(CE2E9E)
$color12 = rgb(0881BC)
$color13 = rgb(C1E177)
$color14 = rgb(FCD0D3)
$color15 = rgb(F5DFE1)

View File

@@ -0,0 +1,30 @@
# Hypridle Configuration
# Device: {{ .deviceProfile }} ({{ .hostname }})
# Idle timeout: {{ .idleTimeout }} seconds
general {
lock_cmd = pidof hyprlock || hyprlock
before_sleep_cmd = loginctl lock-session
after_sleep_cmd = hyprctl dispatch dpms on
}
listener {
timeout = {{ .idleTimeout }}
on-timeout = hyprctl dispatch dpms off
on-resume = hyprctl dispatch dpms on
}
{{- if .hasBattery }}
# Laptop: dim screen before turning off
listener {
timeout = {{ sub .idleTimeout 60 }}
on-timeout = brightnessctl -s set 30%
on-resume = brightnessctl -r
}
# Laptop: suspend after extended idle
listener {
timeout = {{ mul .idleTimeout 2 }}
on-timeout = systemctl suspend
}
{{- end }}

View File

@@ -0,0 +1,293 @@
# Hyprland Configuration
# Device: {{ .deviceProfile }} ({{ .hostname }})
# Managed by chezmoi - edit source at ~/dotfiles
source = ~/.config/hypr/colors.conf
source = ~/.config/hypr/autostart.conf
source = ~/.config/hypr/workspaces.conf
source = ~/.config/hypr/monitors.conf
################
### MONITORS ###
################
# Monitor configuration is in monitors.conf (device-specific)
###################
### MY PROGRAMS ###
###################
$terminal = ghostty
$fileManager = thunar
$menu = wofi --show drun
$run_menu = wofi --show run
#############################
### ENVIRONMENT VARIABLES ###
#############################
env = XCURSOR_SIZE,24
env = HYPRCURSOR_SIZE,24
#####################
### LOOK AND FEEL ###
#####################
general {
gaps_in = 2
gaps_out = 5
border_size = 2
col.active_border = rgba(33ccffee) rgba(00ff99ee) 45deg
col.inactive_border = rgba(595959aa)
resize_on_border = false
allow_tearing = false
layout = dwindle
}
decoration {
rounding = 10
rounding_power = 2
active_opacity = 1.0
inactive_opacity = 1.0
shadow {
enabled = true
range = 4
render_power = 3
color = rgba(1a1a1aee)
}
blur {
enabled = true
size = 3
passes = 1
vibrancy = 0.1696
}
}
animations {
enabled = yes
bezier = easeOutQuint,0.23,1,0.32,1
bezier = easeInOutCubic,0.65,0.05,0.36,1
bezier = linear,0,0,1,1
bezier = almostLinear,0.5,0.5,0.75,1.0
bezier = quick,0.15,0,0.1,1
bezier = snappy,0.2,0.8,0.2,1
bezier = overshoot,0.34,1.56,0.64,1
bezier = bounce,0.68,-0.55,0.27,1.55
bezier = whip,0.1,0.9,0.2,1
animation = global, 1, 5, default
animation = border, 1, 2.5, snappy
animation = windows, 1, 2.8, easeOutQuint
animation = windowsIn, 1, 2.2, bounce, popin 95%
animation = windowsOut, 1, 0.9, whip, popin 85%
animation = fadeIn, 1, 0.9, snappy
animation = fadeOut, 1, 0.8, snappy
animation = fade, 1, 1.6, quick
animation = layers, 1, 2.0, easeOutQuint
animation = layersIn, 1, 1.8, bounce, fade
animation = layersOut, 1, 0.8, whip, fade
animation = fadeLayersIn, 1, 0.9, snappy
animation = fadeLayersOut, 1, 0.7, snappy
animation = workspaces, 1, 1.2, bounce, fade
animation = workspacesIn, 1, 0.9, overshoot, fade
animation = workspacesOut, 1, 1.0, whip, fade
}
# Smart gaps window rules
windowrulev2 = bordersize 0, floating:0, onworkspace:w[tv1]
windowrulev2 = rounding 0, floating:0, onworkspace:w[tv1]
windowrulev2 = bordersize 0, floating:0, onworkspace:f[1]
windowrulev2 = rounding 0, floating:0, onworkspace:f[1]
binds {
hide_special_on_workspace_change = true
workspace_back_and_forth = true
}
ecosystem {
enforce_permissions = true
}
dwindle {
pseudotile = true
preserve_split = true
}
master {
new_status = master
}
misc {
force_default_wallpaper = -1
disable_hyprland_logo = false
enable_anr_dialog = false
}
#############
### INPUT ###
#############
input {
kb_layout = us, ru
kb_variant =
kb_model =
kb_options = caps:escape
kb_rules =
numlock_by_default = true
follow_mouse = 1
sensitivity = 0
{{- if .hasTouchpad }}
touchpad {
natural_scroll = true
tap-to-click = true
disable_while_typing = true
}
}
gestures {
workspace_swipe = true
workspace_swipe_fingers = 3
}
{{- else }}
touchpad {
natural_scroll = false
}
}
{{- end }}
device {
name = epic-mouse-v1
sensitivity = -0.5
}
###################
### KEYBINDINGS ###
###################
$mainMod = SUPER
# Keyboard layout toggle
bind = SUPER, SPACE, exec, bash -c "current=$(hyprctl getoption input:kb_layout -j | jq -r '.str'); if [[ $current == \"us,ru\" ]]; then hyprctl keyword input:kb_layout 'ru,us'; else hyprctl keyword input:kb_layout 'us,ru'; fi"
# Wallpapers
bind = SUPER, bracketright, exec, ~/Scripts/change_wallpaper.sh next
bind = SUPER, bracketleft, exec, ~/Scripts/change_wallpaper.sh prev
# Core bindings
bind = $mainMod, Q, exec, $terminal
bind = $mainMod, K, killactive,
bind = $mainMod, M, exit,
bind = $mainMod, E, exec, ~/.config/hypr/scripts/toggle_expo_on_primary.sh
bind = $mainMod, V, togglefloating,
bind = $mainMod, R, exec, $menu
bind = $mainMod SHIFT, R, exec, hyprctl reload
bind = $mainMod, P, pseudo,
bind = $mainMod, J, togglesplit,
bind = $mainMod, L, exec, pactl set-sink-mute @DEFAULT_SINK@ 1 && hyprlock
bind = $mainMod, t, togglegroup
# VPN switcher
bind = , F6, exec, ~/.config/hypr/scripts/vpn-switcher.sh
# Move focus with mainMod + arrow keys
bind = $mainMod, left, movefocus, l
bind = $mainMod, right, movefocus, r
bind = $mainMod, up, movefocus, u
bind = $mainMod, down, movefocus, d
# Switch workspaces with mainMod + [0-9]
bind = $mainMod, 1, workspace, 1
bind = $mainMod, 2, workspace, 2
bind = $mainMod, 3, workspace, 3
bind = $mainMod, 4, workspace, 4
bind = $mainMod, 5, workspace, 5
bind = $mainMod, 6, workspace, 6
bind = $mainMod, 7, workspace, 7
bind = $mainMod, 8, workspace, 8
bind = $mainMod, 9, workspace, 9
# Special workspaces
bind = SUPER, F12, exec, ~/.config/hypr/scripts/workspace-pin.sh 1337
bind = , F12, togglespecialworkspace, org
bind = SUPER, A, togglespecialworkspace, org
bind = SUPER SHIFT, F12, movetoworkspace, special:org
bind = SUPER, X, workspace, name:media
bind = SUPER SHIFT, X, movetoworkspace, name:media
bind = SUPER SHIFT, C, movetoworkspace, name:tg
bind = SUPER, c, workspace, name:tg
bind = $mainMod, S, togglespecialworkspace, termius
bind = $mainMod SHIFT, S, movetoworkspacesilent, special:termius
bind = $mainMod, G, togglespecialworkspace, llm
bind = $mainMod SHIFT, G, movetoworkspacesilent, special:llm
# Move active window to workspace
bind = $mainMod SHIFT, 1, movetoworkspacesilent, 1
bind = $mainMod SHIFT, 2, movetoworkspacesilent, 2
bind = $mainMod SHIFT, 3, movetoworkspacesilent, 3
bind = $mainMod SHIFT, 4, movetoworkspacesilent, 4
bind = $mainMod SHIFT, 5, movetoworkspacesilent, 5
bind = $mainMod SHIFT, 6, movetoworkspacesilent, 6
bind = $mainMod SHIFT, 7, movetoworkspacesilent, 7
bind = $mainMod SHIFT, 8, movetoworkspacesilent, 8
bind = $mainMod SHIFT, 9, movetoworkspacesilent, 9
bind = $mainMod SHIFT, 0, movetoworkspacesilent, 10
# Screenshot
bind = , Print, exec, ~/.config/hypr/scripts/screenshot.sh
# Scroll through existing workspaces
bind = $mainMod, mouse_down, workspace, e+1
bind = $mainMod, mouse_up, workspace, e-1
# Move/resize windows with mainMod + LMB/RMB
bindm = $mainMod, mouse:272, movewindow
bindm = $mainMod, mouse:273, resizewindow
# Media keys
bindel = ,XF86AudioRaiseVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+
bindel = ,XF86AudioLowerVolume, exec, wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-
bindel = ,XF86AudioMute, exec, wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle
bindel = ,XF86AudioMicMute, exec, wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle
{{- if .hasBattery }}
bindel = ,XF86MonBrightnessUp, exec, brightnessctl s 10%+
bindel = ,XF86MonBrightnessDown, exec, brightnessctl s 10%-
{{- end }}
bindl = , XF86AudioNext, exec, playerctl next
bindl = , XF86AudioPause, exec, playerctl play-pause
bindl = , XF86AudioPlay, exec, playerctl play-pause
bindl = , XF86AudioPrev, exec, playerctl previous
##############################
### WINDOWS AND WORKSPACES ###
##############################
# Window rules for apps
windowrulev2 = workspace name:tg, class:^(org.telegram.desktop)$
windowrulev2 = workspace name:tg, class:^(discord)$
windowrulev2 = suppressevent maximize, class:.*
windowrulev2 = nofocus,class:^$,title:^$,xwayland:1,floating:1,fullscreen:0,pinned:0
#############
### PLUGINS ###
#############
plugin {
hyprexpo {
columns = 3
gap_size = 5
bg_col = rgb(111111)
workspace_method = first 1
{{- if .hasTouchpad }}
enable_gesture = true
gesture_fingers = 3
gesture_distance = 300
gesture_positive = true
{{- else }}
enable_gesture = false
{{- end }}
}
}

View File

@@ -0,0 +1,124 @@
# Hyprlock Configuration
# Sourcing colors
source = $HOME/.config/hypr/colors.conf
$Scripts = $HOME/.config/hypr/scripts
general {
grace = 1
}
background {
monitor =
path = screenshot
blur_size = 5
blur_passes = 1
noise = 0.0117
contrast = 1.3000
brightness = 0.8000
vibrancy = 0.2100
vibrancy_darkness = 0.0
}
input-field {
monitor =
size = 250, 50
outline_thickness = 3
dots_size = 0.33
dots_spacing = 0.15
dots_center = true
outer_color = $color5
inner_color = $color0
font_color = $color12
placeholder_text = <i>Password...</i>
hide_input = false
position = 0, 200
halign = center
valign = bottom
}
# Date
label {
monitor =
text = cmd[update:18000000] date +'%A, %-d %B %Y'
color = $color12
font_size = 34
font_family = JetBrains Mono Nerd Font 10
position = 0, -150
halign = center
valign = top
}
# Week
label {
monitor =
text = cmd[update:18000000] date +'Week %U'
color = $color5
font_size = 24
font_family = JetBrains Mono Nerd Font 10
position = 0, -250
halign = center
valign = top
}
# Time
label {
monitor =
text = cmd[update:1000] date +"%H:%M:%S"
color = $color15
font_size = 94
font_family = JetBrains Mono Nerd Font 10
position = 0, 0
halign = center
valign = center
}
# User
label {
monitor =
text = $USER
color = $color12
font_size = 18
font_family = Inter Display Medium
position = 0, 100
halign = center
valign = bottom
}
# Uptime
label {
monitor =
text = cmd[update:60000] uptime -p
color = $color12
font_size = 24
font_family = JetBrains Mono Nerd Font 10
position = 0, 0
halign = right
valign = bottom
}
# Weather
label {
monitor =
text = cmd[update:3600000] [ -f ~/.cache/.weather_cache ] && cat ~/.cache/.weather_cache
color = $color12
font_size = 24
font_family = JetBrains Mono Nerd Font 10
position = 50, 0
halign = left
valign = bottom
}
# Wallpaper image
image {
monitor =
path = $HOME/.config/hypr/wallpaper_effects/.wallpaper_current
size = 230
rounding = -1
border_size = 2
border_color = $color11
rotate = 0
reload_time = -1
position = 0, 300
halign = center
valign = bottom
}

View File

@@ -0,0 +1,13 @@
# Hyprpaper Configuration
# Device: {{ .deviceProfile }} ({{ .hostname }})
preload = ~/Pictures/wlpp/wallhaven-9dkeqd.png
{{- if eq .deviceProfile "desktop" }}
wallpaper = {{ .primaryMonitor }}, ~/Pictures/wlpp/wallhaven-9dkeqd.png
{{- if .secondaryMonitor }}
wallpaper = {{ .secondaryMonitor }}, ~/Pictures/wlpp/wallhaven-9dkeqd.png
{{- end }}
{{- else }}
wallpaper = {{ .primaryMonitor }}, ~/Pictures/wlpp/wallhaven-9dkeqd.png
{{- end }}

View File

@@ -0,0 +1,21 @@
# Monitor Configuration
# Device: {{ .deviceProfile }} ({{ .hostname }})
{{- if eq .deviceProfile "desktop" }}
# Desktop dual-monitor setup
# Primary: {{ .primaryMonitor }} at {{ .primaryResolution }}
monitor = {{ .primaryMonitor }},{{ .primaryResolution }},0x0,1
{{- if .secondaryMonitor }}
# Secondary: {{ .secondaryMonitor }} at {{ .secondaryResolution }}
# Positioned to the right of primary
monitor = {{ .secondaryMonitor }},{{ .secondaryResolution }},2560x0,1
{{- end }}
{{- else }}
# Laptop single monitor
monitor = {{ .primaryMonitor }},{{ .primaryResolution }},auto,1
# External displays (when docked)
monitor = ,preferred,auto,1
{{- end }}

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env sh
if [ -z "$XDG_PICTURES_DIR" ] ; then
XDG_PICTURES_DIR="$HOME/Pictures"
fi
save_dir="${2:-$XDG_PICTURES_DIR/Screenshots}"
save_file=$(date +'%Y-%m-%d_%H-%M-%S_screenshot.png')
gtkMode=`gsettings get org.gnome.desktop.interface color-scheme | sed "s/'//g" | awk -F '-' '{print $2}'`
ncolor="-h string:bgcolor:#191724 -h string:fgcolor:#faf4ed -h string:frcolor:#56526e"
if [ "${gtkMode}" == "light" ] ; then
ncolor="-h string:bgcolor:#f4ede8 -h string:fgcolor:#9893a5 -h string:frcolor:#908caa"
fi
if [ ! -d "$save_dir" ] ; then
mkdir -p $save_dir
fi
case $1 in
p) grim $save_dir/$save_file ;;
s) grim -g "$(slurp)" - | satty --filename - --fullscreen --output-filename ~/Pictures/Screenshots/satty-$(date '+%Y%m%d-%H:%M:%S').png;;
*) echo "...valid options are..."
echo "p : print screen to $save_dir"
echo "s : snip current screen to $save_dir"
exit 1 ;;
esac
if [ -f "$save_dir/$save_file" ] ; then
notify-send $ncolor "theme" -a "saved in $save_dir" -i "$save_dir/$save_file" -r 91190 -t 2200
fi

View File

@@ -0,0 +1,61 @@
#!/bin/bash
# Get a list of sink names
sinks=($(pactl list sinks short | awk '{print $2}'))
# Get the current default sink name
current_sink=$(pactl info | grep "Default Sink:" | awk '{print $3}')
# Check if we found the current sink
if [ -z "$current_sink" ]; then
echo "Error: Could not determine the current default sink." >&2
# Attempt to set the first sink as default if we can't find the current one
if [ ${#sinks[@]} -gt 0 ]; then
pactl set-default-sink "${sinks[0]}"
echo "Attempting to set default sink to first available: ${sinks[0]}" >&2
exit 0
else
echo "Error: No sinks found." >&2
exit 1
fi
fi
# Find the index of the current sink in the list
current_sink_index=-1
for i in "${!sinks[@]}"; do
if [[ "${sinks[$i]}" == "$current_sink" ]]; then
current_sink_index=$i
break
fi
done
# Check if the current sink was found in the list
if [ "$current_sink_index" -eq -1 ]; then
echo "Error: Current default sink '$current_sink' not found in the list of sinks." >&2
# Reset to the first sink as a fallback
if [ ${#sinks[@]} -gt 0 ]; then
pactl set-default-sink "${sinks[0]}"
echo "Resetting to first available sink: ${sinks[0]}" >&2
exit 0
else
echo "Error: No sinks found to reset to." >&2
exit 1
fi
fi
# Determine the direction
direction=$1
# Calculate the index of the next sink
if [ "$direction" == "up" ]; then
next_sink_index=$(( (current_sink_index - 1 + ${#sinks[@]}) % ${#sinks[@]} ))
else # default to down
next_sink_index=$(( (current_sink_index + 1) % ${#sinks[@]} ))
fi
# Get the name of the next sink
next_sink="${sinks[$next_sink_index]}"
# Set the new default sink
pactl set-default-sink "$next_sink"

View File

@@ -0,0 +1,3 @@
#!/bin/bash
# Just open expo, no special handling
hyprctl dispatch hyprexpo:expo toggle

View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Helper script for vpn-switcher to run privileged commands
# This gets called via pkexec
ACTION="$1"
shift
case "$ACTION" in
wg-up)
wg-quick up "$1"
;;
wg-down)
wg-quick down "$1"
;;
systemctl-start)
systemctl start "$1"
;;
*)
echo "Unknown action: $ACTION" >&2
exit 1
;;
esac

View File

@@ -0,0 +1,238 @@
#!/usr/bin/env python3
import subprocess
import os
# Secrets directory - configurable via chezmoi
SECRETS_DIR = os.path.expanduser("{{ .secretsPath }}")
VPN_DIR = os.path.join(SECRETS_DIR, "vpn") if os.path.isdir(os.path.join(os.path.expanduser("{{ .secretsPath }}"), "vpn")) else os.path.expanduser("~/cfg/vpn")
HELPER = os.path.expanduser("~/.config/hypr/scripts/vpn-switcher-helper.sh")
WOFI_CMD = ["wofi", "--dmenu", "--width", "450", "--height", "350", "--prompt", "VPN Switcher", "--cache-file", "/dev/null"]
def run(cmd, check=False):
"""Run command and return stdout, or None on failure."""
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=check)
return result.stdout.strip()
except subprocess.CalledProcessError:
return None
def notify(msg):
subprocess.run(["notify-send", "VPN Switcher", msg, "-r", "91191", "-t", "2000"])
def get_active_wg():
"""Get list of active WireGuard interfaces."""
out = run(["wg", "show", "interfaces"])
return out.split() if out else []
def is_tailscale_up():
"""Check if tailscale is running."""
result = subprocess.run(["tailscale", "status"], capture_output=True)
return result.returncode == 0
def get_ts_exit_node():
"""Get current tailscale exit node if any."""
if not is_tailscale_up():
return None
out = run(["tailscale", "status", "--json"])
if out:
import json
try:
data = json.loads(out)
return data.get("ExitNodeStatus", {}).get("ID")
except json.JSONDecodeError:
pass
return None
def get_wg_configs():
"""Get list of WireGuard config files."""
configs = []
if os.path.isdir(VPN_DIR):
for f in os.listdir(VPN_DIR):
if f.endswith(".conf"):
configs.append(f[:-5]) # Remove .conf
return sorted(configs)
def get_ts_accounts():
"""Get list of tailscale accounts."""
out = run(["tailscale", "switch", "--list"])
if not out:
return []
accounts = []
for line in out.splitlines()[1:]: # Skip header
parts = line.split()
if len(parts) >= 3:
id_, tailnet, account = parts[0], parts[1], parts[2]
is_active = account.endswith("*")
if is_active:
account = account[:-1]
accounts.append({"id": id_, "tailnet": tailnet, "account": account, "active": is_active})
return accounts
def get_ts_exit_nodes():
"""Get list of available tailscale exit nodes."""
if not is_tailscale_up():
return []
out = run(["tailscale", "exit-node", "list"])
if not out:
return []
nodes = []
for line in out.splitlines():
line = line.strip()
# Skip header, empty lines, and comment lines
if not line or line.startswith("IP") or line.startswith("#"):
continue
parts = line.split()
if len(parts) >= 4:
ip, hostname = parts[0], parts[1]
country = parts[2] if len(parts) > 2 else "-"
city = parts[3] if len(parts) > 3 else "-"
status = " ".join(parts[4:]) if len(parts) > 4 else ""
# Skip offline nodes
if "offline" in status.lower():
continue
# Extract short name (before first dot)
short_name = hostname.split(".")[0]
nodes.append({"hostname": hostname, "short": short_name, "ip": ip, "country": country, "city": city})
return nodes
def build_menu():
"""Build the menu entries."""
lines = []
active_wg = get_active_wg()
ts_exit = get_ts_exit_node()
# WireGuard section
lines.append("━━━ WireGuard ━━━")
for name in get_wg_configs():
status = "●" if name in active_wg else "○"
lines.append(f" {status} {name}")
# Tailscale accounts section
lines.append("━━━ Tailscale Accounts ━━━")
for acc in get_ts_accounts():
status = "●" if acc["active"] else "○"
if acc["active"]:
lines.append(f" {status} {acc['tailnet']} ({acc['account']})")
else:
lines.append(f" {status} {acc['tailnet']} ({acc['account']}) [{acc['id']}]")
# Tailscale exit nodes section
lines.append("━━━ Tailscale Exit Nodes ━━━")
if is_tailscale_up():
if ts_exit:
lines.append(" ● Exit Node Active - Disconnect")
for node in get_ts_exit_nodes():
lines.append(f" ○ {node['hostname']} ({node['country']}/{node['city']})")
else:
lines.append(" ○ Start Tailscale")
# Actions
lines.append("━━━ Actions ━━━")
lines.append(" ⊘ Disconnect All")
return "\n".join(lines)
def pkexec_helper(action, arg):
"""Run helper script via pkexec."""
subprocess.run(["pkexec", HELPER, action, arg], capture_output=True)
def disconnect_all_wg():
"""Disconnect all WireGuard interfaces."""
for iface in get_active_wg():
conf_path = os.path.join(VPN_DIR, f"{iface}.conf")
if os.path.exists(conf_path):
pkexec_helper("wg-down", conf_path)
else:
pkexec_helper("wg-down", iface)
def handle_selection(selection):
"""Handle the user's menu selection."""
# Skip headers and empty
if not selection or selection.startswith("━━━"):
return
# Strip leading whitespace and status icon
clean = selection.strip()
if clean.startswith("●") or clean.startswith("○") or clean.startswith("⊘"):
clean = clean[1:].strip()
# Get current state
active_wg = get_active_wg()
wg_configs = get_wg_configs()
ts_accounts = get_ts_accounts()
ts_exit_nodes = get_ts_exit_nodes()
# Check if it's a WireGuard config
for name in wg_configs:
if clean == name:
if name in active_wg:
# Turn off
conf_path = os.path.join(VPN_DIR, f"{name}.conf")
pkexec_helper("wg-down", conf_path)
notify(f"WireGuard: {name} disconnected")
else:
# Turn on (disable others first)
disconnect_all_wg()
if is_tailscale_up():
run(["tailscale", "set", "--exit-node="])
conf_path = os.path.join(VPN_DIR, f"{name}.conf")
pkexec_helper("wg-up", conf_path)
notify(f"WireGuard: {name} connected")
return
# Check if it's a Tailscale account
for acc in ts_accounts:
if acc["tailnet"] in clean and acc["account"] in clean:
if acc["active"]:
notify("Already on this Tailscale account")
else:
run(["tailscale", "switch", acc["id"]])
notify(f"Tailscale: Switched to {acc['tailnet']}")
return
# Check if it's a Tailscale exit node
if clean == "Exit Node Active - Disconnect":
run(["tailscale", "set", "--exit-node="])
notify("Tailscale exit node disconnected")
return
if clean == "Start Tailscale":
run(["tailscale", "up"])
notify("Tailscale started")
return
for node in ts_exit_nodes:
if node["hostname"] in clean or node["short"] in clean:
disconnect_all_wg()
run(["tailscale", "set", f"--exit-node={node['ip']}"])
notify(f"Tailscale: Connected via {node['short']}")
return
# Actions
if clean == "Disconnect All":
disconnect_all_wg()
if is_tailscale_up():
run(["tailscale", "set", "--exit-node="])
notify("All VPNs disconnected")
def main():
menu = build_menu()
# Run wofi
result = subprocess.run(
WOFI_CMD,
input=menu,
capture_output=True,
text=True
)
selection = result.stdout.strip()
if selection:
handle_selection(selection)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,76 @@
#!/bin/bash
# Configuration
WORKSPACE=$1
CORRECT_PIN="1234"
LOG_FILE="$HOME/.workspace_access_log"
# Fuzzel colors - Nord theme
BG_COLOR="242a36ee"
TEXT_COLOR="eceff4ff"
SELECTION_COLOR="88c0d0ff"
SELECTION_TEXT_COLOR="2e3440ff"
BORDER_COLOR="5e81acff"
# Display an informational notification before showing PIN dialog
notify-send --icon=dialog-password-symbolic \
--app-name="Workspace Security" \
"Workspace $WORKSPACE" "Protected workspace - PIN required" \
--urgency=low
# Get PIN using fuzzel
ENTERED_PIN=$(echo -e "\n" | fuzzel \
--dmenu \
--password \
--prompt="Enter PIN for workspace $WORKSPACE: " \
--width=35 \
--lines=0 \
--horizontal-pad=20 \
--vertical-pad=15 \
--inner-pad=10 \
--border-width=2 \
--border-radius=10 \
--font="JetBrains Mono:size=14" \
--background=${BG_COLOR} \
--text-color=${TEXT_COLOR} \
--match-color=${SELECTION_COLOR} \
--selection-color=${SELECTION_COLOR} \
--selection-text-color=${SELECTION_TEXT_COLOR} \
--border-color=${BORDER_COLOR} )
# Trim whitespace
ENTERED_PIN=$(echo "$ENTERED_PIN" | xargs)
# Check if PIN entry was cancelled (empty)
if [ -z "$ENTERED_PIN" ]; then
notify-send --icon=dialog-information \
--app-name="Workspace Security" \
"Access Cancelled" "PIN entry was cancelled" \
--urgency=low
exit 0
fi
# Verify PIN
if [ "$ENTERED_PIN" == "$CORRECT_PIN" ]; then
# Correct PIN - switch to workspace with nice visual feedback
notify-send --icon=dialog-password \
--app-name="Workspace Security" \
"Access Granted" "Switching to workspace $WORKSPACE" \
--urgency=low
# Optional: Log successful attempt
echo "$(date): Successful PIN entry for workspace $WORKSPACE" >> "$LOG_FILE"
# Small delay for notification visibility before switching
sleep 0.2
hyprctl dispatch workspace "$WORKSPACE"
else
# Wrong PIN - show notification with error icon
notify-send --icon=dialog-error \
--app-name="Workspace Security" \
"Access Denied" "Incorrect PIN for workspace $WORKSPACE" \
--urgency=normal
# Log failed attempt
echo "$(date): Failed PIN attempt for workspace $WORKSPACE" >> "$LOG_FILE"
fi

View File

@@ -0,0 +1,15 @@
# Workspace definitions
workspace = 1, name:WEB
workspace = 2, name:CODE
workspace = 3, name:TERM
workspace = 4, name:IDE
workspace = 5, name:VM
workspace = 6, name:GFX
workspace = 7, name:DOC
workspace = 8, name:GAME
workspace = 9, name:MISC
workspace = 10, name:TMP
# Smart gaps rules
workspace = w[tv1], gapsout:0, gapsin:0
workspace = f[1], gapsout:0, gapsin:0

View File

@@ -0,0 +1,124 @@
// -*- mode: jsonc -*-
// Device: {{ .deviceProfile }} ({{ .hostname }})
{
"height": 30,
"spacing": 6,
{{- if eq .deviceProfile "desktop" }}
"output": ["{{ .primaryMonitor }}"],
{{- end }}
"position": "bottom",
"modules-left": ["hyprland/workspaces"],
"modules-center": [],
"modules-right": [
"custom/vpn",
"pulseaudio#spk",
"pulseaudio#mic",
"network",
{{- if .hasBattery }}
"battery",
"backlight",
{{- end }}
"cpu",
"memory",
"clock",
"tray"
],
"hyprland/workspaces": {
"disable-scroll": true,
"warp-on-scroll": false,
"show-special": true,
"sort-by": "id",
"format": "{name}",
"on-scroll-up": "hyprctl dispatch workspace e+1",
"on-scroll-down": "hyprctl dispatch workspace e-1",
"ignore-workspaces": ["(special:)?chrome-sharing-indicator"],
"format-icons": {
"1": "WEB",
"2": "CODE",
"3": "TERM",
"4": "IDE",
"5": "VM",
"6": "GFX",
"7": "DOC",
"8": "GAME",
"9": "MISC",
"10": "TMP",
"special:termius": "s:term",
"special:org": "s:org",
"special:llm": "s:llm",
"default": "WS"
}
},
"hyprland/window": {
"format": "{title}",
"max-length": 60
},
"pulseaudio#spk": {
"scroll-step": 2,
"format": "VOL {volume}%",
"format-muted": "VOL muted",
"on-click": "pavucontrol -t 3",
"on-click-right": "pactl set-sink-mute @DEFAULT_SINK@ toggle",
"on-scroll-up": "~/.config/hypr/scripts/scroll-audio-sink.sh up",
"on-scroll-down": "~/.config/hypr/scripts/scroll-audio-sink.sh down"
},
"pulseaudio#mic": {
"scroll-step": 2,
"format": "{format_source}",
"format-source": "MIC {volume}%",
"format-source-muted": "MIC muted",
"on-click": "pavucontrol -t 4",
"on-click-right": "pactl set-source-mute @DEFAULT_SOURCE@ toggle",
"on-scroll-up": "pactl set-source-volume @DEFAULT_SOURCE@ +2%",
"on-scroll-down": "pactl set-source-volume @DEFAULT_SOURCE@ -2%"
},
"network": {
"format-wifi": "NET wifi {essid} {signalStrength}%",
"format-ethernet": "NET eth {ipaddr}",
"format-linked": "NET link",
"format-disconnected": "NET down",
"tooltip-format": "{ifname} via {gwaddr}"
},
{{- if .hasBattery }}
"battery": {
"format": "BAT {capacity}%",
"format-charging": "BAT+ {capacity}%",
"format-plugged": "BAT= {capacity}%",
"format-full": "BAT full",
"states": {
"warning": 30,
"critical": 15
}
},
"backlight": {
"format": "BRT {percent}%",
"on-scroll-up": "brightnessctl s 5%+",
"on-scroll-down": "brightnessctl s 5%-"
},
{{- end }}
"cpu": { "format": "CPU {usage}%", "interval": 5, "tooltip": false },
"memory": { "format": "MEM {}%", "interval": 5 },
"clock": {
"format": "TIME {:%Y-%m-%d %H:%M}",
"tooltip-format": "<tt>{calendar}</tt>",
"interval": 60
},
"tray": { "spacing": 4 },
"custom/vpn": {
"exec": "~/.config/waybar/scripts/vpn-status.sh",
"return-type": "json",
"interval": 5,
"on-click": "~/.config/hypr/scripts/vpn-switcher.sh"
}
}

View File

@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# VPN status for waybar - outputs JSON
get_status() {
local wg_status=""
local nb_status=""
local ts_status=""
local text="VPN off"
local tooltip="No VPN active"
local class="disconnected"
local has_tunnel=false # true if WG, NetBird, or TS with exit node
# Check NetBird
if command -v netbird &>/dev/null; then
nb_json=$(netbird status --json 2>/dev/null)
if [[ -n "$nb_json" ]]; then
nb_mgmt=$(echo "$nb_json" | jq -r '.management.connected // false')
if [[ "$nb_mgmt" == "true" ]]; then
nb_ip=$(echo "$nb_json" | jq -r '.netbirdIp // empty' | cut -d'/' -f1)
nb_fqdn=$(echo "$nb_json" | jq -r '.fqdn // empty')
nb_connected=$(echo "$nb_json" | jq -r '.peers.connected // 0')
nb_total=$(echo "$nb_json" | jq -r '.peers.total // 0')
# Extract network name from fqdn (e.g., box-128-45.lumia.overlay -> lumia)
nb_net=$(echo "$nb_fqdn" | awk -F'.' '{print $(NF-1)}')
nb_status="NB:${nb_net}(${nb_connected}/${nb_total})"
tooltip="NetBird: ${nb_fqdn}\nIP: ${nb_ip}\nPeers: ${nb_connected}/${nb_total}"
# NetBird mesh connection alone doesn't route all traffic like a VPN
fi
fi
fi
# Check WireGuard interfaces (excluding NetBird's wt0)
wg_ifaces=$(ip link show type wireguard 2>/dev/null | grep -oP '^\d+: \K[^:]+' | grep -v '^wt')
if [[ -n "$wg_ifaces" ]]; then
wg_name=$(echo "$wg_ifaces" | head -1)
wg_status="WG:$wg_name"
[[ -z "$tooltip" || "$tooltip" == "No VPN active" ]] && tooltip=""
[[ -n "$tooltip" ]] && tooltip+="\n"
tooltip+="WireGuard: $wg_name"
has_tunnel=true
fi
# Check Tailscale
if command -v tailscale &>/dev/null; then
ts_json=$(tailscale status --json 2>/dev/null)
if [[ -n "$ts_json" ]]; then
ts_state=$(echo "$ts_json" | jq -r '.BackendState // empty')
if [[ "$ts_state" == "Running" ]]; then
# Get tailnet name (domain)
tailnet=$(echo "$ts_json" | jq -r '.CurrentTailnet.Name // empty')
tailnet_short="${tailnet%.ts.net}"
tailnet_short="${tailnet_short%.tail*}"
# Check for exit node
exit_node_id=$(echo "$ts_json" | jq -r '.ExitNodeStatus.ID // empty')
exit_online=$(echo "$ts_json" | jq -r '.ExitNodeStatus.Online // false')
if [[ -n "$exit_node_id" && "$exit_online" == "true" ]]; then
exit_host=$(echo "$ts_json" | jq -r '
.Peer | to_entries[] | select(.value.ExitNode == true) | .value.HostName // "exit"
' 2>/dev/null)
[[ -z "$exit_host" || "$exit_host" == "null" ]] && exit_host="exit"
ts_status="TS:${tailnet_short}${exit_host}"
[[ -n "$tooltip" ]] && tooltip+="\n"
tooltip+="Tailscale: ${tailnet}\nExit: ${exit_host}"
has_tunnel=true
else
ts_status="TS:${tailnet_short}"
[[ -n "$tooltip" ]] && tooltip+="\n"
tooltip+="Tailscale: ${tailnet} (no exit)"
# No exit node = no tunnel, don't set has_tunnel
fi
fi
fi
fi
# Build final output
local parts=()
[[ -n "$wg_status" ]] && parts+=("$wg_status")
[[ -n "$nb_status" ]] && parts+=("$nb_status")
[[ -n "$ts_status" ]] && parts+=("$ts_status")
if [[ ${#parts[@]} -gt 0 ]]; then
text=$(IFS=' | '; echo "${parts[*]}")
fi
# Only green if actual tunnel (WG, NetBird, or TS+exit)
if [[ "$has_tunnel" == true ]]; then
class="connected"
elif [[ ${#parts[@]} -gt 0 ]]; then
class="connected-no-tunnel"
fi
printf '{"text": "%s", "tooltip": "%s", "class": "%s"}\n' "$text" "$tooltip" "$class"
}
get_status

View File

@@ -0,0 +1,88 @@
/* ANSI/BBS monospace */
* {
font-family: "Terminus", "IBM Plex Mono", "JetBrainsMono Nerd Font",
monospace;
font-size: 12px;
min-height: 0;
}
window#waybar {
background: #141414;
color: #cccccc;
border: 0px solid #cccccc;
border-radius: 0;
margin: 4px 6px;
padding: 2px 6px;
}
/* Boxy ASCII-ish modules */
#workspaces button,
#custom-vpn,
#pulseaudio,
#network,
#battery,
#backlight,
#cpu,
#memory,
#clock,
#tray {
background: transparent;
color: #cccccc;
border: 1px dashed #cccccc;
border-radius: 0;
padding: 1px 6px;
margin: 0 3px;
}
/* Workspaces: focused/urgent with inverse/red */
#workspaces button:hover {
background: #001900;
}
#workspaces button.focused,
#workspaces button.active {
background: #cccccc;
color: #000;
border-color: #cccccc;
}
#workspaces button.urgent {
background: #ff0000;
color: #000;
border-color: #ff0000;
}
/* Specials: dotted border to mark them */
#workspaces button.special {
border-style: dotted;
}
/* VPN status colors */
#custom-vpn.connected {
color: #00ff00;
border-color: #00ff00;
}
#custom-vpn.connected-no-tunnel {
color: #cccccc;
}
#custom-vpn.disconnected {
color: #666666;
}
/* Battery states */
#battery.warning {
color: #ffaa00;
border-color: #ffaa00;
}
#battery.critical {
color: #ff0000;
border-color: #ff0000;
}
#battery.charging {
color: #00ff00;
}
/* Retro tooltip */
tooltip {
background: #000;
color: #cccccc;
border: 1px solid #cccccc;
}

180
install.sh Executable file
View File

@@ -0,0 +1,180 @@
#!/usr/bin/env bash
#
# Dotfiles Bootstrap & Management Script
#
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
log_info() { echo -e "${BLUE}[INFO]${NC} $*"; }
log_ok() { echo -e "${GREEN}[OK]${NC} $*"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
log_error() { echo -e "${RED}[ERROR]${NC} $*"; }
DISTRO="unknown"
DOTFILES_DIR="$HOME/dotfiles"
detect_distro() {
if [[ -f /etc/arch-release ]]; then
echo "arch"
elif command -v rpm-ostree &>/dev/null; then
echo "fedora-atomic"
elif [[ -f /etc/fedora-release ]]; then
echo "fedora"
else
echo "unknown"
fi
}
install_chezmoi() {
if command -v chezmoi &>/dev/null; then
log_ok "chezmoi already installed: $(chezmoi --version | head -1)"
return 0
fi
log_info "Installing chezmoi..."
case "$DISTRO" in
arch)
sudo pacman -S --noconfirm chezmoi
;;
fedora)
sudo dnf install -y chezmoi
;;
fedora-atomic)
if command -v brew &>/dev/null; then
brew install chezmoi
else
mkdir -p ~/.local/bin
sh -c "$(curl -fsLS get.chezmoi.io)" -- -b ~/.local/bin
export PATH="$HOME/.local/bin:$PATH"
fi
;;
*)
mkdir -p ~/.local/bin
sh -c "$(curl -fsLS get.chezmoi.io)" -- -b ~/.local/bin
export PATH="$HOME/.local/bin:$PATH"
;;
esac
log_ok "chezmoi installed"
}
setup_secrets() {
local secrets_dir="$HOME/secrets"
if [[ ! -d "$secrets_dir" ]]; then
log_info "Creating secrets directory..."
mkdir -p "$secrets_dir/vpn"
chmod 700 "$secrets_dir"
log_ok "Created $secrets_dir"
fi
}
init_chezmoi() {
local repo_url="${1:-}"
if [[ -n "$repo_url" ]]; then
log_info "Initializing from: $repo_url"
chezmoi init "$repo_url"
elif [[ -d "$DOTFILES_DIR/home" ]]; then
log_info "Initializing from local: $DOTFILES_DIR/home"
chezmoi init --source="$DOTFILES_DIR/home" --prompt=false
else
log_error "No repo URL and ~/dotfiles not found"
exit 1
fi
}
is_initialized() {
[[ -f "$HOME/.config/chezmoi/chezmoi.toml" ]] && chezmoi source-path &>/dev/null
}
check_config_outdated() {
# Check if config template has changed
if chezmoi diff 2>&1 | grep -q "config file template has changed"; then
log_warn "Config template changed, regenerating..."
chezmoi init --source="$DOTFILES_DIR/home" --prompt=false
log_ok "Config regenerated"
fi
}
show_menu() {
echo ""
echo " 1) diff - Show pending changes"
echo " 2) apply - Apply dotfiles"
echo " 3) reinit - Re-initialize (change profile)"
echo " 4) update - Pull latest and apply"
echo " 5) edit - Edit a managed file"
echo " q) quit"
echo ""
}
manage_menu() {
while true; do
show_menu
read -rp "Choice: " choice
echo ""
case "$choice" in
1|diff)
chezmoi diff | less -R
;;
2|apply)
chezmoi apply -v
log_ok "Applied!"
;;
3|reinit)
chezmoi init --force
;;
4|update)
chezmoi update -v
log_ok "Updated!"
;;
5|edit)
read -rp "File to edit (e.g. ~/.config/hypr/hyprland.conf): " file
[[ -n "$file" ]] && chezmoi edit "$file"
;;
q|quit|exit)
break
;;
*)
log_warn "Invalid choice"
;;
esac
done
}
main() {
DISTRO=$(detect_distro)
echo ""
echo "========================================"
echo " Dotfiles Manager "
echo "========================================"
echo ""
log_info "Distro: $DISTRO | Host: $(hostname)"
install_chezmoi
setup_secrets
if is_initialized; then
log_ok "Chezmoi already initialized"
check_config_outdated
manage_menu
else
init_chezmoi "${1:-}"
echo ""
log_ok "Bootstrap complete!"
echo ""
read -rp "Apply dotfiles now? [Y/n] " apply
if [[ ! "$apply" =~ ^[Nn]$ ]]; then
chezmoi apply -v
log_ok "Applied!"
else
echo "Run 'chezmoi diff' to review, 'chezmoi apply' to install"
fi
fi
}
main "$@"