12 Commits

Author SHA1 Message Date
ea11fe0eb6 Feature: Make home UniFi VPN row a collapsible card
Put the Replace config option behind an expand chevron, like Mullvad's
options, and frame the card while it's expanded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 21:07:01 +01:00
19e7d3f673 Feature: Add config import button for home UniFi VPN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:45:12 +01:00
bf86ffe258 Feature: Add gavin homelab host to SSH config
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:41:26 +01:00
8d73d2a37e Feature: Add home UniFi WireGuard tunnel to VPN panel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:40:23 +01:00
54b91a72e2 Feature: Add new-install package review list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:24:30 +01:00
788d4939f0 Fix: Drop plain Ctrl+V paste binding in foot
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:23:41 +01:00
9a038b74c3 Fix: Restart portals after env import so screen sharing works
xdg-desktop-portal-hyprland could start before Hyprland imported
WAYLAND_DISPLAY/XDG_CURRENT_DESKTOP, leaving no ScreenCast backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:23:41 +01:00
954dedf7cd Fix: Scale bar volume scroll by distance for touchpads
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:40:27 +01:00
90d0e2bed0 Feature: Show Plexamp icon in media notifications
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:37:01 +01:00
14f72af3f2 Fix: Fall back to grim/slurp for screenshots without hyprshot
Super+Print and Super+Shift+Print use hyprshot when installed and
grim/slurp otherwise. Add Super+Shift+S (area) and Super+F6 (full
screen) for keyboards without a Print key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 03:31:57 +01:00
836848dedd Fix: Bind workspace number keys by name in Lua config
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 02:26:10 +01:00
fbaffc0816 Fix: Use Lua dispatch syntax for bar workspace buttons
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 02:26:10 +01:00
15 changed files with 857 additions and 192 deletions

View File

@@ -50,7 +50,7 @@ regular7=C7C7C7 # white
[key-bindings] [key-bindings]
clipboard-copy=Control+c XF86Copy clipboard-copy=Control+c XF86Copy
clipboard-paste=Control+v Control+Shift+v XF86Paste clipboard-paste=Control+Shift+v XF86Paste
[text-bindings] [text-bindings]
# Ctrl+C now copies, so send the interrupt signal (^C) from Ctrl+Shift+C instead # Ctrl+C now copies, so send the interrupt signal (^C) from Ctrl+Shift+C instead

View File

@@ -29,6 +29,8 @@ hl.monitor({ output = "DP-2", mode = "1920x1080@165.00Hz", position = "0x0", sca
-- (unbind = SUPER SHIFT, Print / unbind = SUPER, up from the source .conf have no Lua equivalent needed — -- (unbind = SUPER SHIFT, Print / unbind = SUPER, up from the source .conf have no Lua equivalent needed —
-- we just don't define a conflicting bind elsewhere; SUPER + up -> movefocus u below is otherwise identical -- we just don't define a conflicting bind elsewhere; SUPER + up -> movefocus u below is otherwise identical
-- to the one already in keybinds.lua, kept here too for parity with the original file) -- to the one already in keybinds.lua, kept here too for parity with the original file)
hl.bind("SUPER + Print", hl.dsp.exec_cmd("hyprshot -o " .. os.getenv("HOME") .. "/Pictures/Screenshots-Linux -m region")) hl.bind("SUPER + Print", hl.dsp.exec_cmd([[sh -c 'if command -v hyprshot >/dev/null; then hyprshot -o "$HOME/Pictures/Screenshots-Linux" -m region; else grim -g "$(slurp)" "$HOME/Pictures/Screenshots-Linux/$(date +%Y-%m-%d_%H-%M-%S).png" && notify-send "Screenshot saved"; fi']])) -- drag an area (hyprshot if installed, else grim/slurp)
hl.bind("SUPER + SHIFT + S", hl.dsp.exec_cmd([[sh -c 'grim -g "$(slurp)" "$HOME/Pictures/Screenshots-Linux/$(date +%Y-%m-%d_%H-%M-%S).png" && notify-send "Screenshot saved"']])) -- region; Surface keyboard has no Print key
hl.bind("SUPER + F6", hl.dsp.exec_cmd([[sh -c 'grim "$HOME/Pictures/Screenshots-Linux/$(date +%Y-%m-%d_%H-%M-%S).png" && notify-send "Screenshot saved"']])) -- full screen
hl.bind("SUPER + up", hl.dsp.focus({ direction = "u" })) hl.bind("SUPER + up", hl.dsp.focus({ direction = "u" }))
hl.bind("SUPER + SHIFT + Print", hl.dsp.exec_cmd("hyprshot -o " .. os.getenv("HOME") .. "/Pictures/Screenshots-Linux -m window")) hl.bind("SUPER + SHIFT + Print", hl.dsp.exec_cmd([[sh -c 'if command -v hyprshot >/dev/null; then hyprshot -o "$HOME/Pictures/Screenshots-Linux" -m window; else grim "$HOME/Pictures/Screenshots-Linux/$(date +%Y-%m-%d_%H-%M-%S).png" && notify-send "Screenshot saved"; fi']])) -- window (hyprshot) or full screen (fallback)

View File

@@ -13,6 +13,8 @@ hl.on("hyprland.start", function()
-- Startup -- Startup
hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP") hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP")
hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP") hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP")
-- restart portals once the env is imported, otherwise screen sharing breaks
hl.exec_cmd("sleep 1 && systemctl --user restart xdg-desktop-portal-hyprland && systemctl --user restart xdg-desktop-portal")
-- Sunshine (virtual monitor only on desktop; skipped on laptops, detected by a system battery) -- 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") hl.exec_cmd("sleep 1 && ! ls -d /sys/class/power_supply/BAT* >/dev/null 2>&1 && hyprctl output create headless")

View File

@@ -116,23 +116,22 @@ hl.bind(mainMod .. " + SHIFT + tab", hl.dsp.focus({ workspace = "m-1" }))
hl.bind(mainMod .. " + U", hl.dsp.workspace.toggle_special("nyx")) hl.bind(mainMod .. " + U", hl.dsp.workspace.toggle_special("nyx"))
hl.bind(mainMod .. " + SHIFT + U", hl.dsp.window.move({ workspace = "special:nyx" })) hl.bind(mainMod .. " + SHIFT + U", hl.dsp.window.move({ workspace = "special:nyx" }))
-- The following mappings use key codes to better support various keyboard layouts -- Number-row keys (the Lua bind parser does not accept the code:NN keycode syntax)
-- 1 is code:10, 2 is code:11, etc
-- Switch workspaces with mainMod + [0-9] -- Switch workspaces with mainMod + [0-9]
for i, code in ipairs({ 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 }) do for i, key in ipairs({ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" }) do
hl.bind(mainMod .. " + code:" .. code, hl.dsp.focus({ workspace = i })) -- code:10 = key 1 ... code:19 = key 0 hl.bind(mainMod .. " + " .. key, hl.dsp.focus({ workspace = i }))
end end
-- Move active window and follow to workspace mainMod + SHIFT + [0-9] -- Move active window and follow to workspace mainMod + SHIFT + [0-9]
for i, code in ipairs({ 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 }) do for i, key in ipairs({ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" }) do
hl.bind(mainMod .. " + SHIFT + code:" .. code, hl.dsp.window.move({ workspace = i, follow = true })) hl.bind(mainMod .. " + SHIFT + " .. key, hl.dsp.window.move({ workspace = i, follow = true }))
end end
hl.bind(mainMod .. " + SHIFT + bracketleft", hl.dsp.window.move({ workspace = -1, follow = true })) -- brackets [ hl.bind(mainMod .. " + SHIFT + bracketleft", hl.dsp.window.move({ workspace = -1, follow = true })) -- brackets [
hl.bind(mainMod .. " + SHIFT + bracketright", hl.dsp.window.move({ workspace = 1, follow = true })) -- brackets ] hl.bind(mainMod .. " + SHIFT + bracketright", hl.dsp.window.move({ workspace = 1, follow = true })) -- brackets ]
-- Move active window to a workspace silently mainMod + CTRL + [0-9] -- Move active window to a workspace silently mainMod + CTRL + [0-9]
for i, code in ipairs({ 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 }) do for i, key in ipairs({ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" }) do
hl.bind(mainMod .. " + CTRL + code:" .. code, hl.dsp.window.move({ workspace = i, follow = false })) hl.bind(mainMod .. " + CTRL + " .. key, hl.dsp.window.move({ workspace = i, follow = false }))
end end
hl.bind(mainMod .. " + CTRL + bracketleft", hl.dsp.window.move({ workspace = -1, follow = false })) -- brackets [ hl.bind(mainMod .. " + CTRL + bracketleft", hl.dsp.window.move({ workspace = -1, follow = false })) -- brackets [
hl.bind(mainMod .. " + CTRL + bracketright", hl.dsp.window.move({ workspace = 1, follow = false })) -- brackets ] hl.bind(mainMod .. " + CTRL + bracketright", hl.dsp.window.move({ workspace = 1, follow = false })) -- brackets ]

View File

@@ -4,6 +4,11 @@
music_icon="$HOME/.config/swaync/icons/music.png" music_icon="$HOME/.config/swaync/icons/music.png"
# Use the app's own icon for known players
case "$(playerctl metadata --format '{{playerName}}' 2>/dev/null)" in
Plexamp) music_icon="com.plexamp.Plexamp" ;;
esac
# Play the next track # Play the next track
play_next() { play_next() {
playerctl next playerctl next

View File

@@ -1,16 +1,17 @@
pragma Singleton pragma Singleton
import QtQuick import QtQuick
// Tailscale, Mullvad, and ProtonVPN all fight over the default route/DNS if // Tailscale, Mullvad, ProtonVPN, and the home UniFi WireGuard tunnel all
// more than one is active at once (see VpnIndicator.qml's header comment) — // fight over the default route/DNS if more than one is active at once (see
// only one of the three should ever actually be connected. This singleton // VpnIndicator.qml's header comment) — only one should ever actually be
// is the shared signal between the two widget files: VpnIndicator.qml // connected. This singleton is the shared signal between the two widget
// writes Mullvad/ProtonVPN's live connection state here as it polls them; // files: VpnIndicator.qml writes each tunnel's live connection state here
// TailscaleIndicator.qml reads it to back off (`tailscale down`) rather // as it polls them; TailscaleIndicator.qml reads it to back off (`tailscale
// than stay up alongside either one, and to refuse turning Tailscale back // down`) rather than stay up alongside one, and to refuse turning Tailscale
// on while one is still connected. // back on while one is still connected.
QtObject { QtObject {
property bool mullvadConnected: false property bool mullvadConnected: false
property bool protonConnected: false property bool protonConnected: false
readonly property bool commercialVpnActive: mullvadConnected || protonConnected property bool homeWgConnected: false
readonly property bool commercialVpnActive: mullvadConnected || protonConnected || homeWgConnected
} }

View File

@@ -54,7 +54,7 @@ Item {
// Set when the VPN-exclusivity guard (below) forces Tailscale down — // Set when the VPN-exclusivity guard (below) forces Tailscale down —
// as opposed to you turning it off yourself — so it knows to bring // as opposed to you turning it off yourself — so it knows to bring
// Tailscale back up automatically once Mullvad/ProtonVPN disconnect, // Tailscale back up automatically once the other VPN disconnects,
// without overriding a deliberate manual "off". // without overriding a deliberate manual "off".
property bool _forcedDownByVpn: false property bool _forcedDownByVpn: false
@@ -204,7 +204,7 @@ Item {
return return
} }
if (VpnExclusivity.commercialVpnActive) { if (VpnExclusivity.commercialVpnActive) {
root.actionStatus = "Disconnect Mullvad/ProtonVPN first" root.actionStatus = "Disconnect the active VPN first"
actionStatusTimer.restart() actionStatusTimer.restart()
return return
} }

View File

@@ -137,15 +137,25 @@ Item {
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
// Scroll distance is accumulated rather than stepping per event:
// a mouse wheel sends one 120-unit event per notch, but a touchpad
// sends a stream of tiny events, which at a fixed step per event
// made touchpad scrolling far too fast. One notch's worth (120) of
// scrolling = one volume step, regardless of input device.
property real wheelAccum: 0
readonly property real volumeStep: 0.05
readonly property real unitsPerStep: 120
onClicked: { onClicked: {
panel.visible = !panel.visible panel.visible = !panel.visible
grab.active = panel.visible grab.active = panel.visible
} }
onWheel: (wheel) => { onWheel: (wheel) => {
if (!root.sink || !root.sink.audio) return if (!root.sink || !root.sink.audio) return
const step = 0.05 wheelAccum += wheel.angleDelta.y
const delta = wheel.angleDelta.y > 0 ? step : -step const steps = Math.trunc(wheelAccum / unitsPerStep)
root.sink.audio.volume = Math.max(0, Math.min(1, root.sink.audio.volume + delta)) if (steps === 0) return
wheelAccum -= steps * unitsPerStep
root.sink.audio.volume = Math.max(0, Math.min(1, root.sink.audio.volume + steps * volumeStep))
} }
} }

View File

@@ -18,6 +18,11 @@ import "../../config"
// - ProtonVPN: official `protonvpn` CLI (proton-vpn-cli package), plain- // - ProtonVPN: official `protonvpn` CLI (proton-vpn-cli package), plain-
// text status only — parsed defensively since its exact wording isn't // text status only — parsed defensively since its exact wording isn't
// guaranteed across versions. // guaranteed across versions.
// - Home (UniFi): the UniFi gateway's built-in WireGuard server, driven
// through a NetworkManager profile via `nmcli` (no sudo needed). The
// row's "Add config" button imports the client .conf UniFi generates
// (via scripts/ImportUnifiWireguard.sh --pick), which names the profile
// to match homeWgConnection below.
Item { Item {
id: root id: root
implicitWidth: label.implicitWidth + 4 implicitWidth: label.implicitWidth + 4
@@ -39,9 +44,17 @@ Item {
property string protonState: "disconnected" property string protonState: "disconnected"
property string protonDetail: "" property string protonDetail: ""
readonly property bool anyConnected: mullvadState === "connected" || protonState === "connected" // NetworkManager profile name for the home UniFi WireGuard tunnel.
// "Installed" here means the profile exists — `nmcli connection show`
// exits non-zero for an unknown name.
property string homeWgConnection: "unifi-home"
property bool homeWgInstalled: true
property string homeWgState: "disconnected"
property string homeWgDetail: ""
// Set while switching from one provider to the other: holds which one readonly property bool anyConnected: mullvadState === "connected" || protonState === "connected" || homeWgState === "connected"
// Set while switching from one provider to another: holds which one
// to connect once the current one has finished disconnecting. // to connect once the current one has finished disconnecting.
property string pendingConnect: "" property string pendingConnect: ""
@@ -51,6 +64,11 @@ Item {
// show up hanging under the other's row. // show up hanging under the other's row.
property string mullvadActionStatus: "" property string mullvadActionStatus: ""
property string protonActionStatus: "" property string protonActionStatus: ""
property string homeWgActionStatus: ""
property bool homeWgImporting: homeWgImportProc.running
// Whether Home's options (Replace config) are expanded — kept across
// panel opens, like mullvadOptionsOpen.
property bool homeWgOptionsOpen: false
// --- Mullvad advanced options (auto-connect, relay location, multihop) -- // --- Mullvad advanced options (auto-connect, relay location, multihop) --
// ProtonVPN's CLI is plain-text-only and far less predictable across // ProtonVPN's CLI is plain-text-only and far less predictable across
@@ -114,6 +132,20 @@ Item {
VpnExclusivity.protonConnected = root.protonState === "connected" VpnExclusivity.protonConnected = root.protonState === "connected"
} }
// `nmcli -g GENERAL.STATE,IP4.ADDRESS connection show <name>` prints
// nothing for an inactive profile, otherwise "activated"/"activating"/
// "deactivating" followed by the tunnel address(es).
function applyHomeWgStatus(text) {
const lines = String(text || "").trim().split("\n").map(function(l) { return l.trim() }).filter(Boolean)
const raw = (lines[0] || "").toLowerCase()
root.homeWgState = raw === "activated" ? "connected"
: raw === "activating" ? "connecting"
: raw === "deactivating" ? "disconnecting"
: "disconnected"
root.homeWgDetail = root.homeWgState === "connected" && lines[1] ? lines[1].split(" | ")[0] : ""
VpnExclusivity.homeWgConnected = root.homeWgState === "connected"
}
function applyMullvadAutoConnect(text) { function applyMullvadAutoConnect(text) {
const m = String(text || "").match(/autoconnect:\s*(on|off)/i) const m = String(text || "").match(/autoconnect:\s*(on|off)/i)
root.mullvadAutoConnect = !!m && m[1].toLowerCase() === "on" root.mullvadAutoConnect = !!m && m[1].toLowerCase() === "on"
@@ -225,33 +257,61 @@ Item {
} }
} }
function refreshHomeWg() {
if (!homeWgStatusProc.running) homeWgStatusProc.running = true
}
function refresh() { function refresh() {
root.refreshMullvad() root.refreshMullvad()
root.refreshProton() root.refreshProton()
root.refreshHomeWg()
}
// Disconnects every provider other than `keep` that isn't already
// down; returns whether it had to, so the caller can defer its own
// connect to root.resumePendingConnect() instead of racing it.
function stopOthers(keep) {
let stopping = false
if (keep !== "mullvad" && root.mullvadState !== "disconnected") { root.disconnectMullvad(); stopping = true }
if (keep !== "protonvpn" && root.protonState !== "disconnected") { root.disconnectProton(); stopping = true }
if (keep !== "homewg" && root.homeWgState !== "disconnected") { root.disconnectHomeWg(); stopping = true }
return stopping
}
// Called from each disconnect's onExited. The provider that just went
// down is marked disconnected optimistically before this runs — its
// status poll is async, and without that the pending connect would see
// it still "connected" and try to disconnect it all over again.
function resumePendingConnect() {
const next = root.pendingConnect
root.pendingConnect = ""
if (next === "mullvad") root.connectMullvad()
else if (next === "protonvpn") root.connectProton()
else if (next === "homewg") root.connectHomeWg()
} }
function connectMullvad() { function connectMullvad() {
if (mullvadConnectProc.running || mullvadDisconnectProc.running) return if (mullvadConnectProc.running || mullvadDisconnectProc.running) return
if (root.protonState !== "disconnected") { if (root.stopOthers("mullvad")) { root.pendingConnect = "mullvad"; return }
root.pendingConnect = "mullvad"
root.disconnectProton()
return
}
root.pendingConnect = "" root.pendingConnect = ""
mullvadConnectProc.running = true mullvadConnectProc.running = true
} }
function connectProton() { function connectProton() {
if (protonConnectProc.running || protonDisconnectProc.running) return if (protonConnectProc.running || protonDisconnectProc.running) return
if (root.mullvadState !== "disconnected") { if (root.stopOthers("protonvpn")) { root.pendingConnect = "protonvpn"; return }
root.pendingConnect = "protonvpn"
root.disconnectMullvad()
return
}
root.pendingConnect = "" root.pendingConnect = ""
protonConnectProc.running = true protonConnectProc.running = true
} }
function connectHomeWg() {
if (homeWgConnectProc.running || homeWgDisconnectProc.running) return
if (root.stopOthers("homewg")) { root.pendingConnect = "homewg"; return }
root.pendingConnect = ""
root.homeWgState = "connecting"
homeWgConnectProc.running = true
}
function disconnectMullvad() { function disconnectMullvad() {
if (mullvadDisconnectProc.running) return if (mullvadDisconnectProc.running) return
mullvadDisconnectProc.running = true mullvadDisconnectProc.running = true
@@ -262,6 +322,12 @@ Item {
protonDisconnectProc.running = true protonDisconnectProc.running = true
} }
function disconnectHomeWg() {
if (homeWgDisconnectProc.running) return
root.homeWgState = "disconnecting"
homeWgDisconnectProc.running = true
}
function toggleMullvad() { function toggleMullvad() {
if (mullvadConnectProc.running || mullvadDisconnectProc.running) return if (mullvadConnectProc.running || mullvadDisconnectProc.running) return
if (root.mullvadState === "connected") root.disconnectMullvad() if (root.mullvadState === "connected") root.disconnectMullvad()
@@ -274,7 +340,23 @@ Item {
else root.connectProton() else root.connectProton()
} }
// Tailscale fights Mullvad/ProtonVPN for the default route/DNS, so fire // Opens a file chooser for the UniFi client .conf and imports it as
// homeWgConnection, replacing any existing profile. The chooser takes
// focus, which clears the focus grab and closes the panel — so the
// import reopens it when done to show the result.
function importHomeWgConfig() {
if (homeWgImportProc.running || root.homeWgState !== "disconnected") return
root.homeWgActionStatus = ""
homeWgImportProc.running = true
}
function toggleHomeWg() {
if (homeWgConnectProc.running || homeWgDisconnectProc.running) return
if (root.homeWgState === "connected") root.disconnectHomeWg()
else root.connectHomeWg()
}
// Tailscale fights Mullvad/ProtonVPN/home WireGuard for the default route/DNS, so fire
// this right after either one finishes connecting — a harmless no-op // this right after either one finishes connecting — a harmless no-op
// if Tailscale was already down. TailscaleIndicator.qml also backs // if Tailscale was already down. TailscaleIndicator.qml also backs
// itself off reactively (via VpnExclusivity) as a backstop, but doing // itself off reactively (via VpnExclusivity) as a backstop, but doing
@@ -336,10 +418,11 @@ Item {
if (exitCode !== 0) { if (exitCode !== 0) {
root.mullvadActionStatus = root.shortLine(mullvadDisconnectErr.text || "Disconnect failed") root.mullvadActionStatus = root.shortLine(mullvadDisconnectErr.text || "Disconnect failed")
mullvadActionStatusTimer.restart() mullvadActionStatusTimer.restart()
} else {
root.mullvadState = "disconnected"
} }
root.refreshMullvad() root.refreshMullvad()
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() } root.resumePendingConnect()
else if (root.pendingConnect === "protonvpn") { root.pendingConnect = ""; root.connectProton() }
} }
} }
@@ -471,10 +554,11 @@ Item {
if (exitCode !== 0) { if (exitCode !== 0) {
root.protonActionStatus = root.shortLine(protonDisconnectErr.text || "Disconnect failed") root.protonActionStatus = root.shortLine(protonDisconnectErr.text || "Disconnect failed")
protonActionStatusTimer.restart() protonActionStatusTimer.restart()
} else {
root.protonState = "disconnected"
} }
root.refreshProton() root.refreshProton()
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() } root.resumePendingConnect()
else if (root.pendingConnect === "protonvpn") { root.pendingConnect = ""; root.connectProton() }
} }
} }
@@ -485,6 +569,75 @@ Item {
onTriggered: if (protonStatusProc.running) protonStatusProc.running = false onTriggered: if (protonStatusProc.running) protonStatusProc.running = false
} }
// --- Home WireGuard (UniFi, via NetworkManager) ------------------------
Process {
id: homeWgStatusProc
command: ["nmcli", "-g", "GENERAL.STATE,IP4.ADDRESS", "connection", "show", root.homeWgConnection]
stdout: StdioCollector {
id: homeWgStatusCollector
onStreamFinished: root.applyHomeWgStatus(homeWgStatusCollector.text)
}
onExited: (exitCode, exitStatus) => {
root.homeWgInstalled = exitCode === 0
if (!root.homeWgInstalled) root.applyHomeWgStatus("")
}
}
// `connection up` blocks until NetworkManager reports the profile
// activated (or gives up), so its exit code is the real result. Note
// WireGuard "activated" only means the interface is configured — the
// handshake itself happens lazily on first traffic.
Process {
id: homeWgConnectProc
command: ["nmcli", "connection", "up", root.homeWgConnection]
stdout: StdioCollector {}
stderr: StdioCollector { id: homeWgConnectErr }
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
root.homeWgActionStatus = root.shortLine(homeWgConnectErr.text || "Connect failed")
homeWgActionStatusTimer.restart()
} else if (!tailscaleDownProc.running) {
tailscaleDownProc.running = true
}
root.refreshHomeWg()
}
}
Process {
id: homeWgDisconnectProc
command: ["nmcli", "connection", "down", root.homeWgConnection]
stdout: StdioCollector {}
stderr: StdioCollector { id: homeWgDisconnectErr }
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0) {
root.homeWgActionStatus = root.shortLine(homeWgDisconnectErr.text || "Disconnect failed")
homeWgActionStatusTimer.restart()
} else {
root.homeWgState = "disconnected"
}
root.refreshHomeWg()
root.resumePendingConnect()
}
}
Process {
id: homeWgImportProc
command: ["bash", Quickshell.shellPath("scripts/ImportUnifiWireguard.sh"),
"--pick", root.homeWgConnection, "--replace"]
stdout: StdioCollector {}
stderr: StdioCollector { id: homeWgImportErr }
onExited: (exitCode, exitStatus) => {
// 3 = chooser cancelled — nothing to report.
if (exitCode === 0) root.homeWgActionStatus = "Config imported"
else if (exitCode !== 3) root.homeWgActionStatus = root.shortLine(homeWgImportErr.text || "Import failed")
if (root.homeWgActionStatus !== "") homeWgActionStatusTimer.restart()
root.refreshHomeWg()
panel.visible = true
grab.active = true
}
}
// --- Shared timers ----------------------------------------------------- // --- Shared timers -----------------------------------------------------
Timer { Timer {
@@ -510,6 +663,13 @@ Item {
onTriggered: root.protonActionStatus = "" onTriggered: root.protonActionStatus = ""
} }
Timer {
id: homeWgActionStatusTimer
interval: 4000
repeat: false
onTriggered: root.homeWgActionStatus = ""
}
// --- Bar icon ------------------------------------------------------------ // --- Bar icon ------------------------------------------------------------
// A fixed, wallpaper-independent green rather than Colors.primary — the // A fixed, wallpaper-independent green rather than Colors.primary — the
@@ -615,6 +775,7 @@ Item {
property string detailText: "" property string detailText: ""
property string state: "disconnected" property string state: "disconnected"
property bool installed: true property bool installed: true
property string missingText: "Not installed"
// When true, shows a chevron that expands a provider-specific // When true, shows a chevron that expands a provider-specific
// options section below this row (used for Mullvad's advanced // options section below this row (used for Mullvad's advanced
// options; ProtonVPN's row leaves this false). // options; ProtonVPN's row leaves this false).
@@ -664,7 +825,7 @@ Item {
} }
Text { Text {
Layout.fillWidth: true Layout.fillWidth: true
text: !provRow.installed ? "Not installed" text: !provRow.installed ? provRow.missingText
: provRow.state === "connecting" ? "Connecting…" : provRow.state === "connecting" ? "Connecting…"
: provRow.state === "disconnecting" ? "Disconnecting…" : provRow.state === "disconnecting" ? "Disconnecting…"
: provRow.state === "connected" ? (provRow.detailText || "Connected") : provRow.state === "connected" ? (provRow.detailText || "Connected")
@@ -848,7 +1009,124 @@ Item {
Column { Column {
width: parent.width width: parent.width
spacing: 2 // Room for the cards' outlines (drawn 3px outside them)
// so two expanded ones don't touch.
spacing: 8
// Same collapsible card as Mullvad below — framed only
// while its options (Replace config) are expanded.
Item {
readonly property bool _framed: root.homeWgOptionsOpen && root.homeWgInstalled
width: parent.width
implicitHeight: homeWgSectionColumn.implicitHeight
Behavior on implicitHeight {
NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic }
}
// Outline offset outside the content rather than drawn on
// its own edge — same as Volume.qml's DeviceRow hover —
// so the row doesn't shift when the options open.
Rectangle {
anchors.fill: parent
anchors.margins: -3
radius: 0
color: "transparent"
border.color: Colors.foreground
border.width: parent._framed ? 1 : 0
}
// Clipped separately so the outline above isn't.
Item {
anchors.fill: parent
clip: true
Column {
id: homeWgSectionColumn
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
spacing: 8
ProviderRow {
label: "Home (UniFi)"
subIcon: "home"
state: root.homeWgState
detailText: root.homeWgDetail
installed: root.homeWgInstalled
missingText: "Not set up"
expandable: root.homeWgInstalled
expanded: root.homeWgOptionsOpen
onToggle: root.toggleHomeWg()
onExpandToggle: root.homeWgOptionsOpen = !root.homeWgOptionsOpen
}
StatusLine { message: root.homeWgActionStatus }
// Imports (or replaces) the UniFi client .conf through a
// file chooser. Shown outright until a profile exists
// (so first-time setup isn't hidden behind the
// chevron), then only inside the expanded options.
// Hidden while connected — replacing deletes the
// profile, which would drop the tunnel.
Item {
width: parent.width
implicitHeight: importRow.implicitHeight + 8
visible: root.homeWgState === "disconnected"
&& (!root.homeWgInstalled || root.homeWgOptionsOpen)
Row {
id: importRow
anchors.left: parent.left
anchors.leftMargin: 34
anchors.verticalCenter: parent.verticalCenter
spacing: 4
opacity: root.homeWgImporting ? 0.5 : 1
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.homeWgInstalled ? "sync" : "upload_file"
font.family: Appearance.iconFontFamilyRounded
font.variableAxes: Appearance.iconFontAxes
font.pixelSize: 13
color: importMouse.containsMouse ? Colors.foreground : Colors.primary
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: root.homeWgImporting ? "Choosing file…"
: root.homeWgInstalled ? "Replace config" : "Add config"
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
font.underline: importMouse.containsMouse
color: importMouse.containsMouse ? Colors.foreground : Colors.primary
}
}
MouseArea {
id: importMouse
anchors.fill: importRow
anchors.margins: -4
enabled: !root.homeWgImporting
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: root.importHomeWgConfig()
}
}
Text {
x: 34
width: parent.width - x
visible: root.homeWgOptionsOpen && root.homeWgInstalled
&& root.homeWgState !== "disconnected"
text: "Disconnect to replace config"
bottomPadding: 6
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
wrapMode: Text.WordWrap
}
}
}
}
// Mullvad's connect row and its advanced options share a // Mullvad's connect row and its advanced options share a
// single bordered card — the border wraps the whole // single bordered card — the border wraps the whole
@@ -856,14 +1134,11 @@ Item {
// grows to include the options/picker as they open, // grows to include the options/picker as they open,
// rather than the options having a separate box of // rather than the options having a separate box of
// their own below an unbordered connect row. // their own below an unbordered connect row.
Rectangle { // Only outlined while the options are expanded.
Item {
readonly property bool _framed: root.mullvadOptionsOpen && root.mullvadInstalled
width: parent.width width: parent.width
radius: 0 implicitHeight: mullvadSectionColumn.implicitHeight
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 // Without this, the card snapped to its new size
// instantly while the popup around it was still // instantly while the popup around it was still
// animating open, so the border visibly lagged // animating open, so the border visibly lagged
@@ -873,171 +1148,188 @@ Item {
NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic } NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic }
} }
Column { // Outline offset outside the content rather than drawn on
id: mullvadSectionColumn // its own edge — same as Volume.qml's DeviceRow hover —
anchors.left: parent.left // so the row doesn't shift when the options open.
anchors.right: parent.right Rectangle {
anchors.top: parent.top anchors.fill: parent
anchors.margins: 8 anchors.margins: -3
spacing: 8 radius: 0
color: "transparent"
border.color: Colors.foreground
border.width: parent._framed ? 1 : 0
}
ProviderRow { // Clipped separately so the outline above isn't.
label: "Mullvad" Item {
subIcon: "gpp_good" anchors.fill: parent
state: root.mullvadState clip: true
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 { Column {
id: mullvadOptionsColumn id: mullvadSectionColumn
width: parent.width anchors.left: parent.left
visible: root.mullvadOptionsOpen && root.mullvadInstalled anchors.right: parent.right
anchors.top: parent.top
spacing: 8 spacing: 8
RowLayout { ProviderRow {
width: parent.width label: "Mullvad"
Text { subIcon: "gpp_good"
Layout.fillWidth: true state: root.mullvadState
text: "Auto-connect" detailText: root.mullvadDetail
font.family: Appearance.fontFamily installed: root.mullvadInstalled
font.pixelSize: Appearance.fontSizeSmall expandable: root.mullvadInstalled
color: Colors.foreground expanded: root.mullvadOptionsOpen
onToggle: root.toggleMullvad()
onExpandToggle: root.openMullvadOptions()
} }
ToggleSwitch {
checked: root.mullvadAutoConnect
busy: mullvadAutoConnectSetProc.running
onToggled: root.setMullvadAutoConnect(!root.mullvadAutoConnect)
}
}
RowLayout { StatusLine { message: root.mullvadActionStatus }
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 { // Advanced options — auto-connect, exit/entry
fieldLabel: root.mullvadMultihop ? "Exit" : "Location" // relay location, multihop.
valueText: root.mullvadLocationLabel Column {
open: root.mullvadPickerTarget === "exit" id: mullvadOptionsColumn
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 width: parent.width
implicitHeight: 24 visible: root.mullvadOptionsOpen && root.mullvadInstalled
radius: 0 spacing: 8
color: Colors.surface
border.color: Colors.muted
border.width: 1
RowLayout {
width: parent.width
Text { Text {
anchors.fill: parent Layout.fillWidth: true
anchors.margins: 6 text: "Auto-connect"
visible: mullvadFilterInput.text.length === 0
text: "Search country…"
font.family: Appearance.fontFamily font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted
}
TextInput {
id: mullvadFilterInput
anchors.fill: parent
anchors.margins: 6
color: Colors.foreground color: Colors.foreground
font.family: Appearance.fontFamily }
font.pixelSize: Appearance.fontSizeSmall ToggleSwitch {
clip: true checked: root.mullvadAutoConnect
focus: root.mullvadPickerTarget !== "" busy: mullvadAutoConnectSetProc.running
text: root.mullvadPickerFilter onToggled: root.setMullvadAutoConnect(!root.mullvadAutoConnect)
onTextChanged: root.mullvadPickerFilter = text
} }
} }
Flickable { RowLayout {
id: mullvadPickerFlick
width: parent.width width: parent.width
height: Math.min(mullvadPickerColumn.implicitHeight, 150) Text {
contentWidth: width Layout.fillWidth: true
contentHeight: mullvadPickerColumn.implicitHeight text: "Multihop"
clip: true font.family: Appearance.fontFamily
boundsBehavior: Flickable.StopAtBounds font.pixelSize: Appearance.fontSizeSmall
color: Colors.foreground
}
ToggleSwitch {
checked: root.mullvadMultihop
busy: mullvadRelaySetProc.running
onToggled: root.setMullvadMultihop(!root.mullvadMultihop)
}
}
Column { LocationField {
id: mullvadPickerColumn fieldLabel: root.mullvadMultihop ? "Exit" : "Location"
width: mullvadPickerFlick.width valueText: root.mullvadLocationLabel
spacing: 1 open: root.mullvadPickerTarget === "exit"
onClicked: root.openMullvadPicker("exit")
}
Repeater { LocationField {
model: [{ name: "Any", code: "any" }].concat(root.mullvadFilteredCountries) visible: root.mullvadMultihop
Rectangle { fieldLabel: "Entry"
required property var modelData valueText: root.mullvadEntryLabel
width: parent.width open: root.mullvadPickerTarget === "entry"
implicitHeight: 22 onClicked: root.openMullvadPicker("entry")
radius: 0 }
color: countryMouse.containsMouse ? Colors.primary : "transparent"
Text { // Shared country picker for whichever LocationField
anchors.left: parent.left // was last clicked — filter box plus a capped,
anchors.right: parent.right // scrollable list ("Any" always pinned first).
anchors.verticalCenter: parent.verticalCenter Column {
anchors.leftMargin: 4 width: parent.width
anchors.rightMargin: 4 visible: root.mullvadPickerTarget !== ""
elide: Text.ElideRight spacing: 6
text: modelData.name
font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall
color: countryMouse.containsMouse ? Colors.primaryText : Colors.foreground
}
MouseArea { Rectangle {
id: countryMouse width: parent.width
anchors.fill: parent implicitHeight: 24
hoverEnabled: true radius: 0
cursorShape: Qt.PointingHandCursor color: Colors.surface
onClicked: root.pickMullvadLocation(modelData.code) 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)
}
} }
} }
} }
} }
} }
} }
} }
} }
} }
@@ -1056,7 +1348,7 @@ Item {
Text { Text {
width: parent.width width: parent.width
text: "Connecting one disconnects the other — only one VPN tunnel at a time." text: "Connecting one disconnects the others — only one VPN tunnel at a time."
font.family: Appearance.fontFamily font.family: Appearance.fontFamily
font.pixelSize: Appearance.fontSizeSmall font.pixelSize: Appearance.fontSizeSmall
color: Colors.muted color: Colors.muted

View File

@@ -37,7 +37,8 @@ RowLayout {
MouseArea { MouseArea {
anchors.fill: parent anchors.fill: parent
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: Hyprland.dispatch("workspace " + modelData.name) // Hyprland's Lua config expects Lua syntax from dispatch
onClicked: Hyprland.dispatch("hl.dsp.focus({ workspace = " + JSON.stringify(modelData.id > 0 ? modelData.id : modelData.name) + " })")
} }
} }
} }

View File

@@ -159,6 +159,21 @@ if [ $have_mullvad -eq 0 ] && [ $have_proton -eq 0 ]; then
warn "neither Mullvad nor ProtonVPN CLI is installed — VpnIndicator.qml will show both as 'Not installed'" warn "neither Mullvad nor ProtonVPN CLI is installed — VpnIndicator.qml will show both as 'Not installed'"
fi fi
# Keep in sync with homeWgConnection in VpnIndicator.qml.
if nmcli -g connection.type connection show unifi-home >/dev/null 2>&1; then
pass "NetworkManager profile 'unifi-home' exists — used by VpnIndicator.qml (Home/UniFi row)"
else
warn "no NetworkManager profile 'unifi-home' — import one from the VPN panel's 'Add config' button (optional)"
fi
# The 'Add config' button's file chooser (scripts/PickFile.py) talks to
# xdg-desktop-portal over D-Bus via PyGObject.
if python3 -c "from gi.repository import Gio" >/dev/null 2>&1; then
pass "PyGObject available — used by PickFile.py (VPN 'Add config' file chooser)"
else
warn "PyGObject (python-gobject) missing — the VPN panel's 'Add config' button can't open a file chooser"
fi
# --- 4. VPN daemons + functional status (Mullvad/ProtonVPN/Tailscale) ------- # --- 4. VPN daemons + functional status (Mullvad/ProtonVPN/Tailscale) -------
# The exclusivity logic (VpnExclusivity.qml backing off Tailscale when a # The exclusivity logic (VpnExclusivity.qml backing off Tailscale when a
# commercial VPN connects) is pure QML and ships with the dotfiles — it # commercial VPN connects) is pure QML and ships with the dotfiles — it

View File

@@ -0,0 +1,84 @@
#!/bin/bash
# Imports a UniFi WireGuard VPN Server client config into NetworkManager
# under the profile name VpnIndicator.qml expects (homeWgConnection,
# "unifi-home" by default), so the bar's VPN panel can toggle it.
#
# Get the .conf from UniFi Network: Settings → VPN → VPN Server → your
# WireGuard server → Add Client → Download.
#
# Usage: ./ImportUnifiWireguard.sh <client.conf> [profile-name] [--replace]
# ./ImportUnifiWireguard.sh --pick [profile-name] [--replace]
#
# --pick opens a file chooser (PickFile.py) instead of taking a path — this
# is what the VPN panel's "Add config" button runs. Exits 3 if cancelled.
#
# NetworkManager names both the profile and the interface after the file's
# basename, so the config is copied to <profile-name>.conf first (interface
# names max out at 15 chars). The private key ends up in a root-only file
# under /etc/NetworkManager/system-connections — delete the downloaded
# .conf afterwards.
set -euo pipefail
replace=0
pick=0
args=()
for a in "$@"; do
case "$a" in
--replace) replace=1 ;;
--pick) pick=1 ;;
-h|--help) sed -n '2,19p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) args+=("$a") ;;
esac
done
if [ $pick -eq 1 ]; then
conf=""
name="${args[0]:-unifi-home}"
rc=0
conf=$("$(dirname "$0")/PickFile.py" "Select UniFi WireGuard config" "*.conf") || rc=$?
if [ $rc -eq 1 ]; then echo "Cancelled" >&2; exit 3; fi
if [ $rc -ne 0 ]; then echo "Couldn't open a file chooser" >&2; exit 1; fi
else
conf="${args[0]:-}"
name="${args[1]:-unifi-home}"
fi
if [ -z "$conf" ] || [ ! -f "$conf" ]; then
echo "Usage: $0 <client.conf> [profile-name] [--replace]" >&2
exit 1
fi
if ! [[ "$name" =~ ^[A-Za-z0-9_-]{1,15}$ ]]; then
echo "Profile name must be 1-15 chars of [A-Za-z0-9_-] (it doubles as the interface name)" >&2
exit 1
fi
if nmcli -g connection.id connection show "$name" >/dev/null 2>&1; then
if [ $replace -eq 1 ]; then
nmcli connection delete "$name" >/dev/null
else
echo "A NetworkManager profile named '$name' already exists — rerun with --replace to overwrite it" >&2
exit 1
fi
fi
tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT
install -m 600 "$conf" "$tmpdir/$name.conf"
if ! grep -qi '^\[Interface\]' "$tmpdir/$name.conf" || ! grep -qi '^\[Peer\]' "$tmpdir/$name.conf"; then
echo "$(basename "$conf") doesn't look like a WireGuard config" >&2
exit 1
fi
nmcli connection import type wireguard file "$tmpdir/$name.conf"
# Only ever brought up deliberately from the bar — never on boot or on
# joining a network, where it'd silently fight Tailscale/Mullvad/ProtonVPN.
nmcli connection modify "$name" connection.autoconnect no
# Import itself defaults to autoconnect=yes, so NetworkManager may already
# have started bringing it up in the moment before the modify above.
nmcli connection down "$name" >/dev/null 2>&1 || true
echo
echo "Imported as '$name'. Toggle it from the VPN panel, or: nmcli connection up $name"
echo "You can now delete $conf"

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
# Opens the xdg-desktop-portal file chooser and prints the picked file's
# path. Quickshell has no usable file dialog of its own (QtQuick.Dialogs'
# fallback renders inside the calling window, i.e. the tiny bar popup), and
# no zenity/kdialog is installed — the portal goes through
# xdg-desktop-portal-gtk, which is.
#
# Usage: PickFile.py [title] [glob ...]
# Exits 0 and prints the path on a pick, 1 on cancel, 2 on error.
import os
import sys
from urllib.parse import unquote, urlparse
from gi.repository import Gio, GLib
title = sys.argv[1] if len(sys.argv) > 1 else "Open File"
globs = sys.argv[2:]
try:
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
except GLib.Error as e:
print(f"No session bus: {e.message}", file=sys.stderr)
sys.exit(2)
# The portal answers on a Request object whose path is derived from our
# unique bus name and handle_token, so subscribe to it before calling —
# otherwise a fast response could arrive before we're listening.
token = f"quickshell_{os.getpid()}"
sender = bus.get_unique_name().lstrip(":").replace(".", "_")
handle = f"/org/freedesktop/portal/desktop/request/{sender}/{token}"
loop = GLib.MainLoop()
result = {"code": 2, "uris": []}
def on_response(_conn, _sender, _path, _iface, _signal, params):
code, results = params.unpack()
result["code"] = code
result["uris"] = results.get("uris", [])
loop.quit()
bus.signal_subscribe("org.freedesktop.portal.Desktop", "org.freedesktop.portal.Request",
"Response", handle, None, Gio.DBusSignalFlags.NONE, on_response)
options = {
"handle_token": GLib.Variant("s", token),
"modal": GLib.Variant("b", True),
}
filters = []
if globs:
filters.append((", ".join(globs), [(0, g) for g in globs]))
filters.append(("All files", [(0, "*")]))
options["filters"] = GLib.Variant("a(sa(us))", filters)
downloads = os.path.expanduser("~/Downloads")
if os.path.isdir(downloads):
options["current_folder"] = GLib.Variant("ay", downloads.encode() + b"\0")
try:
bus.call_sync("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop",
"org.freedesktop.portal.FileChooser", "OpenFile",
GLib.Variant("(ssa{sv})", ("", title, options)),
GLib.VariantType("(o)"), Gio.DBusCallFlags.NONE, -1, None)
except GLib.Error as e:
print(f"File chooser portal unavailable: {e.message}", file=sys.stderr)
sys.exit(2)
loop.run()
# Response codes: 0 = picked, 1 = cancelled, 2 = other failure.
if result["code"] != 0 or not result["uris"]:
sys.exit(1 if result["code"] == 1 else 2)
print(unquote(urlparse(result["uris"][0]).path))

View File

@@ -1,3 +1,7 @@
Host gavin.twhomelab.local
HostName gavin.twhomelab.local
User thoams
Host 192.168.1.87 Host 192.168.1.87
HostName 192.168.1.87 HostName 192.168.1.87
User thoams User thoams
@@ -11,4 +15,4 @@ Host 192.168.1.83
Host 192.168.1.86 Host 192.168.1.86
HostName 192.168.1.86 HostName 192.168.1.86
User thoams User thoams
ForwardAgent yes ForwardAgent yes

176
system/packages-review.txt Normal file
View File

@@ -0,0 +1,176 @@
# ============================================================================
# New-install package review — generated from this machine on 2026-10-11
#
# HOW TO REVIEW
# - Keep a line -> it gets installed
# - Delete the line, or put a "#" at the start -> it gets skipped
# - Everything after "#" on a line is just a description for you
#
# Notes I flagged (look for "!!" below):
# - Hardware-specific packages (CPU microcode, NVIDIA driver) only make
# sense on matching hardware — the script can auto-detect these instead.
# - Some packages are leftovers from other setups (e.g. XFCE, waybar,
# orbit-wifi which you removed in commit 88484f8).
# ============================================================================
## ---------------- Official repos (pacman) ----------------
adw-gtk-theme # Unofficial GTK 3 port of the libadwaita theme
amd-ucode # Microcode update image for AMD CPUs !! AMD CPU only — auto-detect?
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 !! commented out in startup.lua
hyprland # a highly customizable dynamic tiling Wayland compositor
hyprlock # hyprland’s GPU-accelerated screen locking utility !! hypridle is commented out in startup.lua
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 !! NVIDIA GPU only — auto-detect?
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 !! NVIDIA GPU only — auto-detect?
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
## ---------------- AUR (installed via yay) ----------------
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
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 !! -git build, check if still needed
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 (Flathub) ----------------
com.makemkv.MakeMKV # MakeMKV
org.jellyfin.JellyfinDesktop # Jellyfin Desktop
## ---------------- Manually installed binaries (~/.local/bin) ----------------
## These aren't from a package manager; I'd install them via their official installers.
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