diff --git a/.chezmoi.toml.tmpl b/.chezmoi.toml.tmpl new file mode 100644 index 0000000..80a6a3e --- /dev/null +++ b/.chezmoi.toml.tmpl @@ -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 }} diff --git a/.chezmoiignore b/.chezmoiignore new file mode 100644 index 0000000..57d0682 --- /dev/null +++ b/.chezmoiignore @@ -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 diff --git a/README.md b/README.md index 07e87ba..2fc7d54 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/home/Pictures/wlpp/wallhaven-9dkeqd.png b/home/Pictures/wlpp/wallhaven-9dkeqd.png new file mode 100644 index 0000000..eda269d Binary files /dev/null and b/home/Pictures/wlpp/wallhaven-9dkeqd.png differ diff --git a/home/private_dot_config/hypr/autostart.conf.tmpl b/home/private_dot_config/hypr/autostart.conf.tmpl new file mode 100644 index 0000000..7a42872 --- /dev/null +++ b/home/private_dot_config/hypr/autostart.conf.tmpl @@ -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 }} diff --git a/home/private_dot_config/hypr/colors.conf b/home/private_dot_config/hypr/colors.conf new file mode 100644 index 0000000..0932f1b --- /dev/null +++ b/home/private_dot_config/hypr/colors.conf @@ -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) diff --git a/home/private_dot_config/hypr/hypridle.conf.tmpl b/home/private_dot_config/hypr/hypridle.conf.tmpl new file mode 100644 index 0000000..d2499b5 --- /dev/null +++ b/home/private_dot_config/hypr/hypridle.conf.tmpl @@ -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 }} diff --git a/home/private_dot_config/hypr/hyprland.conf.tmpl b/home/private_dot_config/hypr/hyprland.conf.tmpl new file mode 100644 index 0000000..c9eb76b --- /dev/null +++ b/home/private_dot_config/hypr/hyprland.conf.tmpl @@ -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 }} + } +} diff --git a/home/private_dot_config/hypr/hyprlock.conf b/home/private_dot_config/hypr/hyprlock.conf new file mode 100644 index 0000000..5c4b1e1 --- /dev/null +++ b/home/private_dot_config/hypr/hyprlock.conf @@ -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 = Password... + 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 +} diff --git a/home/private_dot_config/hypr/hyprpaper.conf.tmpl b/home/private_dot_config/hypr/hyprpaper.conf.tmpl new file mode 100644 index 0000000..f0a4a74 --- /dev/null +++ b/home/private_dot_config/hypr/hyprpaper.conf.tmpl @@ -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 }} diff --git a/home/private_dot_config/hypr/monitors.conf.tmpl b/home/private_dot_config/hypr/monitors.conf.tmpl new file mode 100644 index 0000000..41f024e --- /dev/null +++ b/home/private_dot_config/hypr/monitors.conf.tmpl @@ -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 }} diff --git a/home/private_dot_config/hypr/scripts/executable_screenshot.sh b/home/private_dot_config/hypr/scripts/executable_screenshot.sh new file mode 100644 index 0000000..785f779 --- /dev/null +++ b/home/private_dot_config/hypr/scripts/executable_screenshot.sh @@ -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 diff --git a/home/private_dot_config/hypr/scripts/executable_scroll-audio-sink.sh b/home/private_dot_config/hypr/scripts/executable_scroll-audio-sink.sh new file mode 100644 index 0000000..0291ff0 --- /dev/null +++ b/home/private_dot_config/hypr/scripts/executable_scroll-audio-sink.sh @@ -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" diff --git a/home/private_dot_config/hypr/scripts/executable_toggle_expo_on_primary.sh b/home/private_dot_config/hypr/scripts/executable_toggle_expo_on_primary.sh new file mode 100644 index 0000000..4e753c7 --- /dev/null +++ b/home/private_dot_config/hypr/scripts/executable_toggle_expo_on_primary.sh @@ -0,0 +1,3 @@ +#!/bin/bash +# Just open expo, no special handling +hyprctl dispatch hyprexpo:expo toggle diff --git a/home/private_dot_config/hypr/scripts/executable_vpn-switcher-helper.sh b/home/private_dot_config/hypr/scripts/executable_vpn-switcher-helper.sh new file mode 100644 index 0000000..5bca822 --- /dev/null +++ b/home/private_dot_config/hypr/scripts/executable_vpn-switcher-helper.sh @@ -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 diff --git a/home/private_dot_config/hypr/scripts/executable_vpn-switcher.sh.tmpl b/home/private_dot_config/hypr/scripts/executable_vpn-switcher.sh.tmpl new file mode 100644 index 0000000..001243e --- /dev/null +++ b/home/private_dot_config/hypr/scripts/executable_vpn-switcher.sh.tmpl @@ -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() diff --git a/home/private_dot_config/hypr/scripts/executable_workspace-pin.sh b/home/private_dot_config/hypr/scripts/executable_workspace-pin.sh new file mode 100644 index 0000000..1d8d3cb --- /dev/null +++ b/home/private_dot_config/hypr/scripts/executable_workspace-pin.sh @@ -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 diff --git a/home/private_dot_config/hypr/workspaces.conf b/home/private_dot_config/hypr/workspaces.conf new file mode 100644 index 0000000..1e9ac12 --- /dev/null +++ b/home/private_dot_config/hypr/workspaces.conf @@ -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 diff --git a/home/private_dot_config/waybar/config.jsonc.tmpl b/home/private_dot_config/waybar/config.jsonc.tmpl new file mode 100644 index 0000000..bdbe820 --- /dev/null +++ b/home/private_dot_config/waybar/config.jsonc.tmpl @@ -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": "{calendar}", + "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" + } +} diff --git a/home/private_dot_config/waybar/scripts/executable_vpn-status.sh b/home/private_dot_config/waybar/scripts/executable_vpn-status.sh new file mode 100644 index 0000000..6ba5098 --- /dev/null +++ b/home/private_dot_config/waybar/scripts/executable_vpn-status.sh @@ -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 diff --git a/home/private_dot_config/waybar/style.css b/home/private_dot_config/waybar/style.css new file mode 100644 index 0000000..689f8b6 --- /dev/null +++ b/home/private_dot_config/waybar/style.css @@ -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; +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..7805501 --- /dev/null +++ b/install.sh @@ -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 "$@" diff --git a/desktop/firefox/userChrome.css b/legacy/desktop/firefox/userChrome.css similarity index 100% rename from desktop/firefox/userChrome.css rename to legacy/desktop/firefox/userChrome.css diff --git a/desktop/i3/config b/legacy/desktop/i3/config similarity index 100% rename from desktop/i3/config rename to legacy/desktop/i3/config diff --git a/desktop/i3blocks.conf b/legacy/desktop/i3blocks.conf similarity index 100% rename from desktop/i3blocks.conf rename to legacy/desktop/i3blocks.conf diff --git a/desktop/i3lock.service b/legacy/desktop/i3lock.service similarity index 100% rename from desktop/i3lock.service rename to legacy/desktop/i3lock.service diff --git a/desktop/i3status/config b/legacy/desktop/i3status/config similarity index 100% rename from desktop/i3status/config rename to legacy/desktop/i3status/config diff --git a/keyboard/Model M/as400.sc b/legacy/keyboard/Model M/as400.sc similarity index 100% rename from keyboard/Model M/as400.sc rename to legacy/keyboard/Model M/as400.sc diff --git a/keyboard/OLKB Preonic/olkb_preonic_rev3.layout.json b/legacy/keyboard/OLKB Preonic/olkb_preonic_rev3.layout.json similarity index 100% rename from keyboard/OLKB Preonic/olkb_preonic_rev3.layout.json rename to legacy/keyboard/OLKB Preonic/olkb_preonic_rev3.layout.json diff --git a/macos/alacritty.yml b/legacy/macos/alacritty.yml similarity index 100% rename from macos/alacritty.yml rename to legacy/macos/alacritty.yml diff --git a/scripts/archive/corona.sh b/legacy/scripts/archive/corona.sh similarity index 100% rename from scripts/archive/corona.sh rename to legacy/scripts/archive/corona.sh diff --git a/scripts/archive/erc20balance.sh b/legacy/scripts/archive/erc20balance.sh similarity index 100% rename from scripts/archive/erc20balance.sh rename to legacy/scripts/archive/erc20balance.sh diff --git a/scripts/disk.sh b/legacy/scripts/disk.sh similarity index 100% rename from scripts/disk.sh rename to legacy/scripts/disk.sh diff --git a/scripts/docker.sh b/legacy/scripts/docker.sh similarity index 100% rename from scripts/docker.sh rename to legacy/scripts/docker.sh diff --git a/scripts/memory.sh b/legacy/scripts/memory.sh similarity index 100% rename from scripts/memory.sh rename to legacy/scripts/memory.sh diff --git a/scripts/moc_status.sh b/legacy/scripts/moc_status.sh similarity index 100% rename from scripts/moc_status.sh rename to legacy/scripts/moc_status.sh diff --git a/scripts/rofi/rofi-openvpn.sh b/legacy/scripts/rofi/rofi-openvpn.sh similarity index 100% rename from scripts/rofi/rofi-openvpn.sh rename to legacy/scripts/rofi/rofi-openvpn.sh diff --git a/scripts/rofi/rofi-ssh.sh b/legacy/scripts/rofi/rofi-ssh.sh similarity index 100% rename from scripts/rofi/rofi-ssh.sh rename to legacy/scripts/rofi/rofi-ssh.sh diff --git a/setup.sh b/legacy/setup.sh similarity index 100% rename from setup.sh rename to legacy/setup.sh diff --git a/shell/.archive/wmfsrc b/legacy/shell/.archive/wmfsrc similarity index 100% rename from shell/.archive/wmfsrc rename to legacy/shell/.archive/wmfsrc diff --git a/shell/.bashrc b/legacy/shell/.bashrc similarity index 100% rename from shell/.bashrc rename to legacy/shell/.bashrc diff --git a/shell/.tmux.conf b/legacy/shell/.tmux.conf similarity index 100% rename from shell/.tmux.conf rename to legacy/shell/.tmux.conf diff --git a/shell/.vimrc b/legacy/shell/.vimrc similarity index 100% rename from shell/.vimrc rename to legacy/shell/.vimrc diff --git a/shell/.xinitrc b/legacy/shell/.xinitrc similarity index 100% rename from shell/.xinitrc rename to legacy/shell/.xinitrc diff --git a/shell/.zimrc b/legacy/shell/.zimrc similarity index 100% rename from shell/.zimrc rename to legacy/shell/.zimrc diff --git a/shell/.zshrc b/legacy/shell/.zshrc similarity index 100% rename from shell/.zshrc rename to legacy/shell/.zshrc