Compare commits
11 Commits
2f9dfda410
...
features/f
| Author | SHA1 | Date | |
|---|---|---|---|
| 98f9a77b23 | |||
| bc242ae956 | |||
| 4237b2ed9e | |||
| 7b0cf9934b | |||
| 9df5731ee1 | |||
| 3806935316 | |||
| 516f8eed82 | |||
| 369179c057 | |||
| 322804577f | |||
| 0eee14858b | |||
| 461f4c3fff |
@@ -59,7 +59,7 @@ List of programs and tools I use.
|
||||
|📁 File Manager | [thunar](https://wiki.archlinux.org/title/Thunar) |
|
||||
|📝 Editor | [vs code](https://github.com/microsoft/vscode) / [neovim (changing to soon)](https://github.com/neovim/neovim) |
|
||||
|🌐 Browser | [zen](https://github.com/zen-browser/desktop) / [firefox](https://github.com/topics/firefox-browser) / [startpage](https://blog-bucket-personal.s3.eu-west-2.amazonaws.com/Screenshot_02-May_08-44-23_6614.png) |
|
||||
|📊 Bar | [waybar](https://github.com/Alexays/Waybar) |
|
||||
|📊 Bar | [waybar](https://github.com/Alexays/Waybar) / [quickshell](https://quickshell.org/) |
|
||||
|🚀 Launcher | [rofi](https://github.com/davatorium/rofi) |
|
||||
|🔒 Lockscreen | [hyprlock](https://github.com/hyprwm/hyprlock) |
|
||||
|🚪 Login Menu | [sddm](https://github.com/sddm/sddm) |
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
-- Ported from UserConfigs/UserAnimations.conf (the active preset)
|
||||
--
|
||||
-- The 14 alternative presets from animations/*.conf were also ported, flat, as
|
||||
-- anim-*.lua (see the plan) — none of them are wired in. To switch styles, replace
|
||||
-- the body of this file with `require("anim-<preset>")`, e.g. require("anim-ml4w-classic").
|
||||
-- anim-*.lua (see the plan) — none of them are wired in. They now live in ../presets/.
|
||||
-- To switch styles, replace the body of this file with `require("presets.anim-<preset>")`,
|
||||
-- e.g. require("presets.anim-ml4w-classic").
|
||||
|
||||
hl.config({
|
||||
animations = {
|
||||
@@ -6,7 +6,7 @@
|
||||
-- Animation - https://wiki.hyprland.org/Configuring/Animations/
|
||||
-- Decoration - https://wiki.hyprland.org/Configuring/Variables/#decoration
|
||||
|
||||
local colors = require("colors")
|
||||
local colors = require("appearance.colors")
|
||||
|
||||
hl.config({
|
||||
general = {
|
||||
@@ -33,6 +33,9 @@ hl.on("hyprland.start", function()
|
||||
-- Starting hypridle to start hyprlock
|
||||
hl.exec_cmd("hypridle")
|
||||
|
||||
-- Float + center the Bitwarden extension pop-out (window rules can't catch it — see the script)
|
||||
hl.exec_cmd(scriptsDir .. "/FloatBitwardenPopup.sh")
|
||||
|
||||
hl.dispatch(hl.dsp.focus({ workspace = 3 }))
|
||||
|
||||
-- NOTE: the trailing "&&" here is a stray leftover from the original .conf (dangling, does nothing) — ported as-is
|
||||
@@ -2,9 +2,13 @@
|
||||
-- Ported from hyprland.conf — see https://wiki.hyprland.org/
|
||||
--
|
||||
-- This mirrors the old source= chain 1:1, just as require()'d Lua modules instead
|
||||
-- of hyprlang includes. See each file for its origin. The animations/ preset library
|
||||
-- of hyprlang includes. See each file for its origin. The presets/ preset library
|
||||
-- (anim-*.lua) is also ported but intentionally not required from here — only the
|
||||
-- currently-active style (animations.lua) is wired in, exactly like before.
|
||||
-- currently-active style (appearance/animations.lua) is wired in, exactly like before.
|
||||
--
|
||||
-- Modules live in subfolders sorted by concern (core/, appearance/, layout/, input/,
|
||||
-- presets/) — require() paths below use dotted module names, which Lua resolves to
|
||||
-- the matching subfolder (e.g. require("core.env") -> core/env.lua).
|
||||
|
||||
-- Initial boot script: applies initial wallpapers, theming, new settings etc.
|
||||
-- suggest not changing/deleting this, including the reference file at ~/.config/hypr/.initial_startup_done
|
||||
@@ -14,17 +18,17 @@ hl.on("hyprland.start", function()
|
||||
end)
|
||||
|
||||
-- ## This is where you want to start tinkering
|
||||
require("startup") -- put your start-up packages in startup.lua
|
||||
require("env") -- Environment variables to load
|
||||
require("windowrules") -- all about Hyprland Window Rules and Layer Rules
|
||||
require("decorations") -- Decorations config
|
||||
require("animations") -- Animation config
|
||||
require("keybinds") -- Put your own keybinds here
|
||||
require("settings") -- Main Hyprland Settings
|
||||
require("core.startup") -- put your start-up packages in core/startup.lua
|
||||
require("core.env") -- Environment variables to load
|
||||
require("layout.windowrules") -- all about Hyprland Window Rules and Layer Rules
|
||||
require("appearance.decorations") -- Decorations config
|
||||
require("appearance.animations") -- Animation config
|
||||
require("input.keybinds") -- Put your own keybinds here
|
||||
require("core.settings") -- Main Hyprland Settings
|
||||
|
||||
-- nwg-displays / hyprdynamicmonitors
|
||||
require("monitors")
|
||||
require("workspaces")
|
||||
require("layout.monitors")
|
||||
require("layout.workspaces")
|
||||
|
||||
-- NOTE: the original hyprland.conf used a plain `exec` (not `exec-once`) here, so it
|
||||
-- re-ran on every `hyprctl reload`, not just at startup. hl.on("hyprland.start", ...)
|
||||
@@ -35,4 +39,4 @@ hl.on("hyprland.start", function()
|
||||
end)
|
||||
|
||||
-- HyprMod managed settings
|
||||
require("hyprmod")
|
||||
require("core.hyprmod")
|
||||
|
||||
73
dotfiles/.config/hypr/scripts/FloatBitwardenPopup.sh
Executable file
73
dotfiles/.config/hypr/scripts/FloatBitwardenPopup.sh
Executable file
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
# Float + resize (to 781x702) + center the Bitwarden browser-extension pop-out window.
|
||||
#
|
||||
# Why this exists instead of a window rule: Hyprland's `float`/`center`/`size`
|
||||
# rules are "static" — evaluated exactly once, at the instant a window is
|
||||
# created — and this popup opens with the generic title "Zen Browser" (same
|
||||
# initialTitle/initialClass as the main browser window), only updating to
|
||||
# "Extension: (Bitwarden Password Manager) - Bitwarden..." a moment after
|
||||
# it's already mapped. By then the static rule has already missed its one
|
||||
# chance and there's no field available at creation time to tell the popup
|
||||
# apart from a normal browser window. See layout/windowrules.lua and
|
||||
# https://github.com/hyprwm/Hyprland/issues/7402 for the same problem class.
|
||||
#
|
||||
# No socat/ncat is installed, so instead of an event-driven IPC listener this
|
||||
# just polls `hyprctl clients` at a short interval with jq (already
|
||||
# installed).
|
||||
#
|
||||
# Dispatch syntax note: Hyprland 0.55+'s Lua config changed `hyprctl dispatch`
|
||||
# to take a literal Lua expression calling hl.dsp.* (e.g.
|
||||
# `hyprctl dispatch 'hl.dsp.window.center()'`), not the old
|
||||
# "centerwindow"-style dispatcher strings. Verified live against the real
|
||||
# API in /usr/share/hypr/stubs/hl.meta.lua before wiring this in.
|
||||
#
|
||||
# Why it re-asserts for a few seconds instead of firing once: right after the
|
||||
# popup leaves the tiled layout, Hyprland briefly re-tiles the sibling window
|
||||
# to fill the freed space, and during that reflow this popup was observed
|
||||
# reporting `floating: false` again for one poll tick — snapping it back to
|
||||
# its old tiled geometry. Re-asserting float+center for a short settle window
|
||||
# after first detection rides that out. After the settle window it's left
|
||||
# alone, so a manual re-tile/move by you later isn't fought.
|
||||
|
||||
TITLE_PATTERN='Extension: \(Bitwarden Password Manager\)'
|
||||
POLL_INTERVAL=0.3
|
||||
SETTLE_TICKS=6 # ~2s of re-assertion after first detection, at POLL_INTERVAL
|
||||
TARGET_WIDTH=781
|
||||
TARGET_HEIGHT=702
|
||||
|
||||
declare -A ticks_left # address -> remaining re-assert ticks
|
||||
|
||||
while true; do
|
||||
sleep "$POLL_INTERVAL"
|
||||
|
||||
# Forget any tracked window that's no longer open.
|
||||
open_addresses=$(hyprctl clients -j | jq -r '.[].address')
|
||||
for addr in "${!ticks_left[@]}"; do
|
||||
grep -qF "$addr" <<<"$open_addresses" || unset 'ticks_left[$addr]'
|
||||
done
|
||||
|
||||
# Process substitution (not a pipe) so the while loop runs in this shell
|
||||
# and its writes to ticks_left actually persist across iterations.
|
||||
while IFS= read -r win; do
|
||||
title=$(jq -r '.title' <<<"$win")
|
||||
[[ "$title" =~ $TITLE_PATTERN ]] || continue
|
||||
|
||||
address=$(jq -r '.address' <<<"$win")
|
||||
floating=$(jq -r '.floating' <<<"$win")
|
||||
|
||||
# First time we've seen this window: start its settle window.
|
||||
[[ -v ticks_left[$address] ]] || ticks_left[$address]=$SETTLE_TICKS
|
||||
|
||||
# Already floating and past its settle window: leave it alone.
|
||||
[[ "$floating" == "true" && ${ticks_left[$address]} -le 0 ]] && continue
|
||||
|
||||
hyprctl dispatch "hl.dsp.focus({ window = \"address:$address\" })" >/dev/null
|
||||
hyprctl dispatch 'hl.dsp.window.float({ action = "enable" })' >/dev/null
|
||||
# resize({x=W,y=H}) sets the absolute size (not a delta) — verified live.
|
||||
# Must happen before center(), since resizing shifts what "centered" means.
|
||||
hyprctl dispatch "hl.dsp.window.resize({ x = $TARGET_WIDTH, y = $TARGET_HEIGHT })" >/dev/null
|
||||
hyprctl dispatch 'hl.dsp.window.center()' >/dev/null
|
||||
|
||||
ticks_left[$address]=$((ticks_left[$address] - 1))
|
||||
done < <(hyprctl clients -j | jq -c '.[] | {address, title, floating}')
|
||||
done
|
||||
16
dotfiles/.config/quickshell/config/VpnExclusivity.qml
Normal file
16
dotfiles/.config/quickshell/config/VpnExclusivity.qml
Normal file
@@ -0,0 +1,16 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
|
||||
// Tailscale, Mullvad, and ProtonVPN all fight over the default route/DNS if
|
||||
// more than one is active at once (see VpnIndicator.qml's header comment) —
|
||||
// only one of the three should ever actually be connected. This singleton
|
||||
// is the shared signal between the two widget files: VpnIndicator.qml
|
||||
// writes Mullvad/ProtonVPN's live connection state here as it polls them;
|
||||
// TailscaleIndicator.qml reads it to back off (`tailscale down`) rather
|
||||
// than stay up alongside either one, and to refuse turning Tailscale back
|
||||
// on while one is still connected.
|
||||
QtObject {
|
||||
property bool mullvadConnected: false
|
||||
property bool protonConnected: false
|
||||
readonly property bool commercialVpnActive: mullvadConnected || protonConnected
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
module config
|
||||
singleton Colors 1.0 Colors.qml
|
||||
singleton Appearance 1.0 Appearance.qml
|
||||
singleton VpnExclusivity 1.0 VpnExclusivity.qml
|
||||
|
||||
@@ -46,7 +46,9 @@ PanelWindow {
|
||||
rightMargin: Appearance.spacing
|
||||
}
|
||||
spacing: Appearance.spacing
|
||||
Tray {}
|
||||
//Tray {}
|
||||
TailscaleIndicator { barWindow: bar }
|
||||
VpnIndicator { barWindow: bar }
|
||||
NetworkIndicator { barWindow: bar }
|
||||
BluetoothIndicator { barWindow: bar }
|
||||
Volume { barWindow: bar }
|
||||
|
||||
1095
dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml
Normal file
1095
dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml
Normal file
File diff suppressed because it is too large
Load Diff
554
dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml
Normal file
554
dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml
Normal file
@@ -0,0 +1,554 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import Quickshell.Io
|
||||
import "../../config"
|
||||
|
||||
// A switcher between commercial VPN providers, distinct from
|
||||
// TailscaleIndicator.qml — Tailscale is a mesh overlay network you generally
|
||||
// leave running; these are traditional single-tunnel VPN clients where only
|
||||
// one makes sense to have active at a time. Connecting one here disconnects
|
||||
// the other first.
|
||||
//
|
||||
// V1 scope: connect (provider's own "fastest"/default) and disconnect per
|
||||
// provider, live status, no region/server picker yet. Provider CLIs:
|
||||
// - Mullvad: official `mullvad` CLI (mullvad-vpn-daemon package), has
|
||||
// real JSON status output.
|
||||
// - ProtonVPN: official `protonvpn` CLI (proton-vpn-cli package), plain-
|
||||
// text status only — parsed defensively since its exact wording isn't
|
||||
// guaranteed across versions.
|
||||
Item {
|
||||
id: root
|
||||
implicitWidth: label.implicitWidth + 4
|
||||
implicitHeight: Appearance.barHeight
|
||||
|
||||
required property var barWindow
|
||||
|
||||
property int refreshIntervalSec: 20
|
||||
readonly property int _refreshIntervalMs: Math.max(5, Math.min(3600, refreshIntervalSec)) * 1000
|
||||
|
||||
// Whether each CLI is on PATH at all. Starts optimistic so the panel
|
||||
// doesn't flash "not installed" before the first `which` check lands.
|
||||
property bool mullvadInstalled: true
|
||||
property bool protonInstalled: true
|
||||
|
||||
// "disconnected" | "connecting" | "connected" | "disconnecting"
|
||||
property string mullvadState: "disconnected"
|
||||
property string mullvadDetail: ""
|
||||
property string protonState: "disconnected"
|
||||
property string protonDetail: ""
|
||||
|
||||
readonly property bool anyConnected: mullvadState === "connected" || protonState === "connected"
|
||||
|
||||
// Set while switching from one provider to the other: holds which one
|
||||
// to connect once the current one has finished disconnecting.
|
||||
property string pendingConnect: ""
|
||||
|
||||
// Surfaced under the header for a failed connect/disconnect; cleared
|
||||
// automatically after a few seconds.
|
||||
property string actionStatus: ""
|
||||
|
||||
function shortLine(text) {
|
||||
const line = String(text || "").trim().split("\n")[0] || ""
|
||||
return line.length > 80 ? line.substring(0, 77) + "…" : line
|
||||
}
|
||||
|
||||
function applyMullvadStatus(text) {
|
||||
try {
|
||||
const data = JSON.parse(text)
|
||||
const state = String(data.state || "disconnected")
|
||||
const loc = data.details && data.details.location
|
||||
root.mullvadState = state
|
||||
root.mullvadDetail = loc ? [loc.city, loc.country].filter(Boolean).join(", ") : ""
|
||||
} catch (e) {
|
||||
root.mullvadState = "disconnected"
|
||||
root.mullvadDetail = ""
|
||||
}
|
||||
VpnExclusivity.mullvadConnected = root.mullvadState === "connected"
|
||||
}
|
||||
|
||||
// No --json here — ProtonVPN's CLI only prints plain text, so this
|
||||
// just looks for a "Status: …" line and, best-effort, a following
|
||||
// "Server:"/"Country:" line if the CLI happens to print one.
|
||||
function applyProtonStatus(text) {
|
||||
const body = String(text || "")
|
||||
const statusMatch = body.match(/^Status:\s*(.+)$/mi)
|
||||
const raw = statusMatch ? statusMatch[1].trim().toLowerCase() : ""
|
||||
root.protonState = raw.indexOf("connected") === 0 ? "connected" : "disconnected"
|
||||
const serverMatch = body.match(/^Server:\s*(.+)$/mi)
|
||||
const countryMatch = body.match(/^Country:\s*(.+)$/mi)
|
||||
root.protonDetail = serverMatch ? serverMatch[1].trim() : (countryMatch ? countryMatch[1].trim() : "")
|
||||
VpnExclusivity.protonConnected = root.protonState === "connected"
|
||||
}
|
||||
|
||||
function refreshMullvad() {
|
||||
if (!root.mullvadInstalled) {
|
||||
if (!mullvadWhichProc.running) mullvadWhichProc.running = true
|
||||
return
|
||||
}
|
||||
if (!mullvadStatusProc.running) {
|
||||
mullvadStatusProc.running = true
|
||||
mullvadPollWatchdog.restart()
|
||||
}
|
||||
}
|
||||
|
||||
function refreshProton() {
|
||||
if (!root.protonInstalled) {
|
||||
if (!protonWhichProc.running) protonWhichProc.running = true
|
||||
return
|
||||
}
|
||||
if (!protonStatusProc.running) {
|
||||
protonStatusProc.running = true
|
||||
protonPollWatchdog.restart()
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
root.refreshMullvad()
|
||||
root.refreshProton()
|
||||
}
|
||||
|
||||
function connectMullvad() {
|
||||
if (mullvadConnectProc.running || mullvadDisconnectProc.running) return
|
||||
if (root.protonState !== "disconnected") {
|
||||
root.pendingConnect = "mullvad"
|
||||
root.disconnectProton()
|
||||
return
|
||||
}
|
||||
root.pendingConnect = ""
|
||||
mullvadConnectProc.running = true
|
||||
}
|
||||
|
||||
function connectProton() {
|
||||
if (protonConnectProc.running || protonDisconnectProc.running) return
|
||||
if (root.mullvadState !== "disconnected") {
|
||||
root.pendingConnect = "protonvpn"
|
||||
root.disconnectMullvad()
|
||||
return
|
||||
}
|
||||
root.pendingConnect = ""
|
||||
protonConnectProc.running = true
|
||||
}
|
||||
|
||||
function disconnectMullvad() {
|
||||
if (mullvadDisconnectProc.running) return
|
||||
mullvadDisconnectProc.running = true
|
||||
}
|
||||
|
||||
function disconnectProton() {
|
||||
if (protonDisconnectProc.running) return
|
||||
protonDisconnectProc.running = true
|
||||
}
|
||||
|
||||
function toggleMullvad() {
|
||||
if (mullvadConnectProc.running || mullvadDisconnectProc.running) return
|
||||
if (root.mullvadState === "connected") root.disconnectMullvad()
|
||||
else root.connectMullvad()
|
||||
}
|
||||
|
||||
function toggleProton() {
|
||||
if (protonConnectProc.running || protonDisconnectProc.running) return
|
||||
if (root.protonState === "connected") root.disconnectProton()
|
||||
else root.connectProton()
|
||||
}
|
||||
|
||||
// Tailscale fights Mullvad/ProtonVPN for the default route/DNS, so fire
|
||||
// this right after either one finishes connecting — a harmless no-op
|
||||
// if Tailscale was already down. TailscaleIndicator.qml also backs
|
||||
// itself off reactively (via VpnExclusivity) as a backstop, but doing
|
||||
// it here too avoids waiting out its poll interval.
|
||||
Process {
|
||||
id: tailscaleDownProc
|
||||
command: ["tailscale", "down"]
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector {}
|
||||
}
|
||||
|
||||
// --- Mullvad ---------------------------------------------------------
|
||||
|
||||
Process {
|
||||
id: mullvadWhichProc
|
||||
command: ["which", "mullvad"]
|
||||
running: true
|
||||
stdout: StdioCollector {}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.mullvadInstalled = exitCode === 0
|
||||
if (root.mullvadInstalled) root.refreshMullvad()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: mullvadStatusProc
|
||||
command: ["mullvad", "status", "--json"]
|
||||
stdout: StdioCollector {
|
||||
id: mullvadStatusCollector
|
||||
onStreamFinished: root.applyMullvadStatus(mullvadStatusCollector.text)
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0 && !mullvadWhichProc.running) mullvadWhichProc.running = true
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: mullvadConnectProc
|
||||
command: ["mullvad", "connect", "--wait"]
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector { id: mullvadConnectErr }
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0) {
|
||||
root.actionStatus = "Mullvad: " + root.shortLine(mullvadConnectErr.text || "connect failed")
|
||||
actionStatusTimer.restart()
|
||||
} else if (!tailscaleDownProc.running) {
|
||||
tailscaleDownProc.running = true
|
||||
}
|
||||
root.refreshMullvad()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: mullvadDisconnectProc
|
||||
command: ["mullvad", "disconnect", "--wait"]
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector { id: mullvadDisconnectErr }
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0) {
|
||||
root.actionStatus = "Mullvad: " + root.shortLine(mullvadDisconnectErr.text || "disconnect failed")
|
||||
actionStatusTimer.restart()
|
||||
}
|
||||
root.refreshMullvad()
|
||||
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() }
|
||||
else if (root.pendingConnect === "protonvpn") { root.pendingConnect = ""; root.connectProton() }
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: mullvadPollWatchdog
|
||||
interval: 15000
|
||||
repeat: false
|
||||
onTriggered: if (mullvadStatusProc.running) mullvadStatusProc.running = false
|
||||
}
|
||||
|
||||
// --- ProtonVPN ---------------------------------------------------------
|
||||
|
||||
Process {
|
||||
id: protonWhichProc
|
||||
command: ["which", "protonvpn"]
|
||||
running: true
|
||||
stdout: StdioCollector {}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.protonInstalled = exitCode === 0
|
||||
if (root.protonInstalled) root.refreshProton()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: protonStatusProc
|
||||
command: ["protonvpn", "status"]
|
||||
stdout: StdioCollector {
|
||||
id: protonStatusCollector
|
||||
onStreamFinished: root.applyProtonStatus(protonStatusCollector.text)
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0 && !protonWhichProc.running) protonWhichProc.running = true
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: protonConnectProc
|
||||
command: ["protonvpn", "connect"]
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector { id: protonConnectErr }
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0) {
|
||||
root.actionStatus = "ProtonVPN: " + root.shortLine(protonConnectErr.text || "connect failed")
|
||||
actionStatusTimer.restart()
|
||||
} else if (!tailscaleDownProc.running) {
|
||||
tailscaleDownProc.running = true
|
||||
}
|
||||
root.refreshProton()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: protonDisconnectProc
|
||||
command: ["protonvpn", "disconnect"]
|
||||
stdout: StdioCollector {}
|
||||
stderr: StdioCollector { id: protonDisconnectErr }
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0) {
|
||||
root.actionStatus = "ProtonVPN: " + root.shortLine(protonDisconnectErr.text || "disconnect failed")
|
||||
actionStatusTimer.restart()
|
||||
}
|
||||
root.refreshProton()
|
||||
if (root.pendingConnect === "mullvad") { root.pendingConnect = ""; root.connectMullvad() }
|
||||
else if (root.pendingConnect === "protonvpn") { root.pendingConnect = ""; root.connectProton() }
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: protonPollWatchdog
|
||||
interval: 15000
|
||||
repeat: false
|
||||
onTriggered: if (protonStatusProc.running) protonStatusProc.running = false
|
||||
}
|
||||
|
||||
// --- Shared timers -----------------------------------------------------
|
||||
|
||||
Timer {
|
||||
id: refreshTimer
|
||||
interval: root._refreshIntervalMs
|
||||
repeat: true
|
||||
running: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: actionStatusTimer
|
||||
interval: 4000
|
||||
repeat: false
|
||||
onTriggered: root.actionStatus = ""
|
||||
}
|
||||
|
||||
// --- Bar icon ------------------------------------------------------------
|
||||
|
||||
Text {
|
||||
id: label
|
||||
anchors.centerIn: parent
|
||||
font.family: Appearance.iconFontFamily
|
||||
font.variableAxes: Appearance.iconFontAxes
|
||||
font.pixelSize: Appearance.iconSize
|
||||
color: root.anyConnected ? Colors.primary : Colors.muted
|
||||
text: "vpn_lock"
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: {
|
||||
panel.visible = !panel.visible
|
||||
grab.active = panel.visible
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reusable pieces -----------------------------------------------------
|
||||
|
||||
component ToggleSwitch: Item {
|
||||
id: toggleRoot
|
||||
property bool checked: false
|
||||
property bool busy: false
|
||||
signal toggled()
|
||||
implicitWidth: 34
|
||||
implicitHeight: 18
|
||||
opacity: toggleRoot.enabled ? 1 : 0.4
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 0
|
||||
color: toggleRoot.checked ? Colors.primary : Colors.muted
|
||||
opacity: (toggleRoot.checked ? 1 : 0.35) * (toggleRoot.busy ? 0.6 : 1)
|
||||
}
|
||||
Rectangle {
|
||||
width: parent.height - 4
|
||||
height: parent.height - 4
|
||||
radius: 0
|
||||
color: Colors.background
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
x: toggleRoot.checked ? parent.width - width - 2 : 2
|
||||
Behavior on x { NumberAnimation { duration: Appearance.animationDuration } }
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: !toggleRoot.busy
|
||||
cursorShape: toggleRoot.busy ? Qt.ArrowCursor : Qt.PointingHandCursor
|
||||
onClicked: toggleRoot.toggled()
|
||||
}
|
||||
}
|
||||
|
||||
// A single provider's row — icon, name, live status line, and a toggle
|
||||
// that connects/disconnects it (routed back through root so it can
|
||||
// enforce the one-provider-at-a-time rule).
|
||||
component ProviderRow: Rectangle {
|
||||
id: provRow
|
||||
property string label: ""
|
||||
property string subIcon: "shield"
|
||||
property string detailText: ""
|
||||
property string state: "disconnected"
|
||||
property bool installed: true
|
||||
signal toggle()
|
||||
width: parent ? parent.width : 0
|
||||
radius: 0
|
||||
implicitHeight: rowContent.implicitHeight + 12
|
||||
color: provRow.state === "connected"
|
||||
? Qt.rgba(Colors.primary.r, Colors.primary.g, Colors.primary.b, 0.18)
|
||||
: "transparent"
|
||||
|
||||
RowLayout {
|
||||
id: rowContent
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.margins: 6
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
Layout.preferredWidth: 20
|
||||
Layout.minimumWidth: 20
|
||||
Layout.maximumWidth: 20
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
text: provRow.subIcon
|
||||
font.family: Appearance.iconFontFamily
|
||||
font.variableAxes: Appearance.iconFontAxes
|
||||
font.pixelSize: 14
|
||||
color: provRow.state === "connected" ? Colors.primary : Colors.foreground
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 1
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: provRow.label
|
||||
elide: Text.ElideRight
|
||||
font.family: Appearance.fontFamily
|
||||
font.pixelSize: Appearance.fontSize
|
||||
font.bold: provRow.state === "connected"
|
||||
color: Colors.foreground
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: !provRow.installed ? "Not installed"
|
||||
: provRow.state === "connecting" ? "Connecting…"
|
||||
: provRow.state === "disconnecting" ? "Disconnecting…"
|
||||
: provRow.state === "connected" ? (provRow.detailText || "Connected")
|
||||
: "Disconnected"
|
||||
elide: Text.ElideRight
|
||||
font.family: Appearance.fontFamily
|
||||
font.pixelSize: Appearance.fontSizeSmall
|
||||
color: Colors.muted
|
||||
}
|
||||
}
|
||||
|
||||
ToggleSwitch {
|
||||
enabled: provRow.installed
|
||||
checked: provRow.state === "connected" || provRow.state === "connecting"
|
||||
busy: provRow.state === "connecting" || provRow.state === "disconnecting"
|
||||
onToggled: provRow.toggle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Panel -----------------------------------------------------------
|
||||
|
||||
PopupWindow {
|
||||
id: panel
|
||||
visible: false
|
||||
color: "transparent"
|
||||
readonly property int contentWidth: 244
|
||||
readonly property int contentPadding: 16
|
||||
implicitWidth: contentWidth + contentPadding * 2
|
||||
implicitHeight: mainColumn.implicitHeight + contentPadding * 2
|
||||
Behavior on implicitHeight {
|
||||
NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic }
|
||||
}
|
||||
|
||||
onVisibleChanged: if (visible) root.refresh()
|
||||
|
||||
anchor {
|
||||
window: root.barWindow
|
||||
rect.x: 0
|
||||
rect.y: 0
|
||||
rect.width: root.barWindow.width
|
||||
rect.height: root.barWindow.height
|
||||
edges: Edges.Bottom | Edges.Right
|
||||
gravity: Edges.Bottom | Edges.Left
|
||||
margins.bottom: -4
|
||||
margins.right: 0
|
||||
}
|
||||
|
||||
HyprlandFocusGrab {
|
||||
id: grab
|
||||
windows: [panel]
|
||||
onCleared: panel.visible = false
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
anchors.fill: parent
|
||||
radius: 0
|
||||
color: Colors.surface
|
||||
border.color: Colors.muted
|
||||
border.width: 3
|
||||
|
||||
Column {
|
||||
id: mainColumn
|
||||
anchors.fill: parent
|
||||
anchors.margins: panel.contentPadding
|
||||
spacing: 10
|
||||
|
||||
RowLayout {
|
||||
width: parent.width
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
text: "vpn_lock"
|
||||
font.family: Appearance.iconFontFamily
|
||||
font.variableAxes: Appearance.iconFontAxes
|
||||
font.pixelSize: 22
|
||||
color: root.anyConnected ? Colors.primary : Colors.foreground
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "VPN"
|
||||
font.family: Appearance.fontFamily
|
||||
font.pixelSize: 18
|
||||
font.bold: true
|
||||
color: Colors.foreground
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: root.actionStatus !== ""
|
||||
width: parent.width
|
||||
text: root.actionStatus
|
||||
font.family: Appearance.fontFamily
|
||||
font.pixelSize: Appearance.fontSizeSmall
|
||||
color: Colors.muted
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
spacing: 2
|
||||
|
||||
ProviderRow {
|
||||
label: "Mullvad"
|
||||
subIcon: "shield"
|
||||
state: root.mullvadState
|
||||
detailText: root.mullvadDetail
|
||||
installed: root.mullvadInstalled
|
||||
onToggle: root.toggleMullvad()
|
||||
}
|
||||
ProviderRow {
|
||||
label: "ProtonVPN"
|
||||
subIcon: "gpp_good"
|
||||
state: root.protonState
|
||||
detailText: root.protonDetail
|
||||
installed: root.protonInstalled
|
||||
onToggle: root.toggleProton()
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
text: "Connecting one disconnects the other — only one VPN tunnel at a time."
|
||||
font.family: Appearance.fontFamily
|
||||
font.pixelSize: Appearance.fontSizeSmall
|
||||
color: Colors.muted
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,15 @@ PanelWindow {
|
||||
color: "transparent"
|
||||
exclusiveZone: 0
|
||||
|
||||
anchors { bottom: true }
|
||||
margins.bottom: 70
|
||||
// Top-right, just under the bar — same fixed distance from the bar and
|
||||
// screen edge (Appearance.spacing) that every popup opened from the bar
|
||||
// itself uses, so this lines up with them instead of floating
|
||||
// bottom-center.
|
||||
anchors { top: true; right: true }
|
||||
margins.top: Appearance.spacing
|
||||
margins.right: Appearance.spacing
|
||||
|
||||
implicitWidth: 240
|
||||
implicitWidth: 272
|
||||
implicitHeight: 54
|
||||
|
||||
WlrLayershell.layer: WlrLayer.Overlay
|
||||
@@ -59,19 +64,61 @@ PanelWindow {
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Item {
|
||||
id: slider
|
||||
Layout.fillWidth: true
|
||||
height: 6
|
||||
radius: 3
|
||||
color: Colors.muted
|
||||
implicitHeight: 16
|
||||
|
||||
readonly property real value: osd.sink && osd.sink.audio ? osd.sink.audio.volume : 0
|
||||
readonly property bool enabled: !!osd.sink
|
||||
|
||||
Rectangle {
|
||||
height: parent.height
|
||||
radius: 3
|
||||
color: Colors.primary
|
||||
width: parent.width * (osd.sink && osd.sink.audio ? osd.sink.audio.volume : 0)
|
||||
Behavior on width { NumberAnimation { duration: Appearance.animationDuration } }
|
||||
id: track
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: parent.width
|
||||
height: 4
|
||||
radius: 0
|
||||
color: Colors.muted
|
||||
opacity: 0.35
|
||||
}
|
||||
Rectangle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
anchors.left: parent.left
|
||||
width: track.width * Math.max(0, Math.min(1, slider.value))
|
||||
height: 4
|
||||
radius: 0
|
||||
color: Colors.primary
|
||||
opacity: slider.enabled ? 1 : 0.4
|
||||
}
|
||||
Rectangle {
|
||||
width: 12
|
||||
height: 12
|
||||
radius: 6
|
||||
color: Colors.primary
|
||||
opacity: slider.enabled ? 1 : 0.4
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
x: Math.max(0, Math.min(track.width - width, track.width * slider.value - width / 2))
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: slider.enabled
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
function updateFromX(mx) {
|
||||
if (!osd.sink || !osd.sink.audio) return
|
||||
osd.sink.audio.volume = Math.max(0, Math.min(1, mx / track.width))
|
||||
}
|
||||
onPressed: (mouse) => updateFromX(mouse.x)
|
||||
onPositionChanged: (mouse) => { if (pressed) updateFromX(mouse.x) }
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.preferredWidth: 32
|
||||
horizontalAlignment: Text.AlignRight
|
||||
font.family: Appearance.fontFamily
|
||||
font.pixelSize: Appearance.fontSizeSmall
|
||||
color: Colors.muted
|
||||
text: Math.round(slider.value * 100) + "%"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user