diff --git a/dotfiles/.config/quickshell/config/VpnExclusivity.qml b/dotfiles/.config/quickshell/config/VpnExclusivity.qml index d39f4f0..a20116d 100644 --- a/dotfiles/.config/quickshell/config/VpnExclusivity.qml +++ b/dotfiles/.config/quickshell/config/VpnExclusivity.qml @@ -7,11 +7,39 @@ import QtQuick // connected. This singleton is the shared signal between the two widget // files: VpnIndicator.qml writes each tunnel's live connection state here // as it polls them; TailscaleIndicator.qml reads it to back off (`tailscale -// down`) rather than stay up alongside one, and to refuse turning Tailscale -// back on while one is still connected. +// down`) rather than stay up alongside one, to refuse turning Tailscale +// back on while one is still connected, and to bring Tailscale up on its +// own whenever none of them is. +// +// TailscaleIndicator.qml publishes its own state back here too, so +// NetworkIndicator.qml can name whichever tunnel is up under the public IP. QtObject { property bool mullvadConnected: false property bool protonConnected: false property bool homeWgConnected: false readonly property bool commercialVpnActive: mullvadConnected || protonConnected || homeWgConnected + + // Also published by VpnIndicator.qml: whether every provider has + // reported its state at least once (until then the flags above are just + // their defaults), and whether any of them is mid connect, disconnect or + // switch (when "nothing connected" is only momentary). noVpnActive is + // the settled "none of them is connected" that TailscaleIndicator.qml + // auto-connects on. + property bool vpnStatesKnown: false + property bool vpnTransitioning: false + readonly property bool noVpnActive: vpnStatesKnown && !commercialVpnActive && !vpnTransitioning + + property bool tailscaleConnected: false + // Name of the exit node in use, "" if none. + property string tailscaleExitNode: "" + + // e.g. "ProtonVPN", or "Tailscale (exit node: nas)"; "" when nothing is + // up. Normally one name, but lists every tunnel it sees, since one can + // briefly overlap another while switching. + readonly property string activeVpnLabel: [ + mullvadConnected ? "Mullvad" : "", + protonConnected ? "ProtonVPN" : "", + homeWgConnected ? "Home (UniFi)" : "", + tailscaleConnected ? "Tailscale" + (tailscaleExitNode ? " (exit node: " + tailscaleExitNode + ")" : "") : "" + ].filter(Boolean).join(", ") } diff --git a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml index 66f856d..3ad001d 100644 --- a/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/NetworkIndicator.qml @@ -601,6 +601,17 @@ Item { publicIpProc.running = true } + // Same for a VPN coming up or going down — the public IP is then the + // tunnel's exit (or no longer is). + Connections { + target: VpnExclusivity + function onActiveVpnLabelChanged() { + root.publicIp = "—" + if (panel.visible && root.connectedDevice !== null && !publicIpProc.running) + publicIpProc.running = true + } + } + Timer { interval: 60000 repeat: true @@ -1049,6 +1060,30 @@ Item { } } + // Which VPN (if any) the public IP above is coming through. + Row { + width: parent.width + spacing: 4 + visible: root.connectedDevice !== null && VpnExclusivity.activeVpnLabel !== "" + + Text { + text: "VPN:" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + Text { + width: parent.width - x + text: VpnExclusivity.activeVpnLabel + elide: Text.ElideRight + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.foreground + font.bold: true + } + } + // Captive portal banner — shown when the connectivity probe // above finds either a confirmed sign-in page or a // "limited" result standing between this network and the diff --git a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml index c49b4dd..ff5a5e6 100644 --- a/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/TailscaleIndicator.qml @@ -52,11 +52,15 @@ Item { property bool _loginPending: false property bool _authUrlOpened: false - // Set when the VPN-exclusivity guard (below) forces Tailscale down — - // as opposed to you turning it off yourself — so it knows to bring - // Tailscale back up automatically once the other VPN disconnects, - // without overriding a deliberate manual "off". - property bool _forcedDownByVpn: false + // Tailscale is the fallback whenever none of the VPN providers is + // connected — see maybeAutoConnect(). _manualOff is set when you switch + // it off yourself with no provider connected, so that sticks; it's + // cleared once a provider connects, so Tailscale still comes back after + // that one disconnects. _autoConnectTried allows one automatic `up` per + // stretch of no provider being connected, so a failing one doesn't + // retry (and re-report its failure) on every poll. + property bool _manualOff: false + property bool _autoConnectTried: false // Other logged-in Tailscale accounts on this machine (`tailscale switch // --list`) — separate tailnets/identities you can flip between, not @@ -147,6 +151,8 @@ Item { function applyStatus(text) { const next = root.parseTailscaleStatus(text) root.status = next + VpnExclusivity.tailscaleConnected = next.running + VpnExclusivity.tailscaleExitNode = next.running && next.activeExitNode ? next.activeExitNode.name : "" if (root._desired !== -1 && next.running === (root._desired === 1)) root._desired = -1 if (next.running) { root._loginPending = false @@ -159,7 +165,6 @@ Item { if (VpnExclusivity.commercialVpnActive && !root.busy) { root.busy = true root._desired = 0 - root._forcedDownByVpn = true root.actionStatus = "Tailscale stopped — a VPN provider is active" actionStatusTimer.restart() actionProc.command = ["tailscale", "down"] @@ -168,6 +173,22 @@ Item { } else if (next.needsLogin && next.authUrl !== "" && !root._authUrlOpened) { root.openAuthUrl(next.authUrl) } + root.maybeAutoConnect() + } + + // Only acts on a confirmed "Stopped": not NeedsLogin (`tailscale up` + // would open a login page in the browser unasked), nor a daemon that + // isn't answering yet. + function shouldAutoConnect() { + return VpnExclusivity.noVpnActive && !root._manualOff && !root._autoConnectTried + && root.installed && !root.busy && root.status.backendState === "Stopped" + } + + // Waits out autoConnectTimer first rather than connecting right away — + // see there. + function maybeAutoConnect() { + if (!root.shouldAutoConnect()) autoConnectTimer.stop() + else if (!autoConnectTimer.running) autoConnectTimer.start() } function openAuthUrl(url) { @@ -192,10 +213,10 @@ Item { function toggleTailscale() { if (root.busy || !root.installed) return - // Any manual action overrides the guard's own memory of why - // Tailscale went down — a deliberate "off" here should stick even - // once the VPN it deferred to eventually disconnects. - root._forcedDownByVpn = false + // A deliberate "off" with no VPN connected shouldn't be undone by + // auto-connect straight away. + root._manualOff = root.active && !VpnExclusivity.commercialVpnActive + autoConnectTimer.stop() if (root.active) { root.busy = true root._desired = 0 @@ -325,19 +346,34 @@ Item { } } - // Auto-resumes Tailscale once neither commercial VPN is connected - // anymore — but only if it was this guard that took Tailscale down in - // the first place, never overriding a manual "off". + // A provider connecting starts things afresh for when it next + // disconnects: a manual "off" from before no longer applies, and + // auto-connect gets a new attempt. Connections { target: VpnExclusivity function onCommercialVpnActiveChanged() { - if (!VpnExclusivity.commercialVpnActive && root._forcedDownByVpn && !root.active) { - root._forcedDownByVpn = false - root.actionStatus = "Resuming Tailscale — VPN disconnected" - actionStatusTimer.restart() - root.bringTailscaleUp() + if (VpnExclusivity.commercialVpnActive) { + root._manualOff = false + root._autoConnectTried = false } } + function onNoVpnActiveChanged() { root.maybeAutoConnect() } + } + + // A short grace period before auto-connecting, as a backstop against + // momentary gaps vpnTransitioning doesn't cover (e.g. one provider's + // disconnect finishing a moment before the next one's connect starts). + Timer { + id: autoConnectTimer + interval: 3000 + repeat: false + onTriggered: { + if (!root.shouldAutoConnect()) return + root._autoConnectTried = true + root.bringTailscaleUp() + root.actionStatus = "Connecting Tailscale — no VPN active" + actionStatusTimer.restart() + } } // Background poll, independent of whether the panel is open, so the bar diff --git a/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml index 1628be7..541df79 100644 --- a/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml +++ b/dotfiles/.config/quickshell/modules/bar/VpnIndicator.qml @@ -37,6 +37,9 @@ Item { // doesn't flash "not installed" before the first `which` check lands. property bool mullvadInstalled: true property bool protonInstalled: true + // From `protonvpn info` — optimistic for the same reason as above, so + // the row doesn't flash "Signed out" before the first check lands. + property bool protonSignedIn: true // "disconnected" | "connecting" | "connected" | "disconnecting" property string mullvadState: "disconnected" @@ -54,6 +57,12 @@ Item { readonly property bool anyConnected: mullvadState === "connected" || protonState === "connected" || homeWgState === "connected" + // Whether each provider has reported its state (or turned out not to + // be installed) at least once — see VpnExclusivity.vpnStatesKnown. + property bool _mullvadKnown: false + property bool _protonKnown: false + property bool _homeWgKnown: false + // Set while switching from one provider to another: holds which one // to connect once the current one has finished disconnecting. property string pendingConnect: "" @@ -65,15 +74,34 @@ Item { property string mullvadActionStatus: "" property string protonActionStatus: "" property string homeWgActionStatus: "" + // Shown at the bottom of the panel only when connecting one VPN had to + // take another down — e.g. "Disconnected Mullvad — only one VPN at a + // time." Cleared after a few seconds, or when the panel closes. + property string exclusivityNote: "" + + // The Install / Uninstall job running through scripts/VpnPackage.sh, as + // ":" (e.g. "mullvad:install"); "" when idle. One at a + // time — pacman holds a lock anyway. + property string packageJob: "" + // Which provider's Uninstall link is waiting on a second, confirming + // click ("mullvad" | "proton" | ""). Reset when the panel closes. + property string uninstallConfirm: "" property bool homeWgImporting: homeWgImportProc.running + // Sign-in errors stay up until the next attempt (no timer) — unlike + // the action statuses, they're something to read and act on. + property string protonSigninStatus: "" + // The sign-in form only opens from pressing ProtonVPN's connect toggle + // while signed out; closed again with the panel. + property bool protonSigninOpen: false + property bool protonSigningIn: protonSigninProc.running + // Password (and 2FA code) waiting to be written to the signin + // process's stdin once it starts; cleared as soon as it's written. + property string _protonSigninInput: "" // 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) -- - // ProtonVPN's CLI is plain-text-only and far less predictable across - // versions, so this extra panel is Mullvad-specific rather than a - // second ProviderRow feature. property bool mullvadOptionsOpen: false property bool mullvadAutoConnect: false @@ -89,14 +117,57 @@ Item { property var mullvadCountries: [] // "" | "exit" | "entry" — which location field's picker is open, if any. property string mullvadPickerTarget: "" - property string mullvadPickerFilter: "" - readonly property var mullvadFilteredCountries: { - const f = root.mullvadPickerFilter.trim().toLowerCase() - if (!f) return root.mullvadCountries - return root.mullvadCountries.filter(function(c) { - return c.name.toLowerCase().indexOf(f) !== -1 || c.code.indexOf(f) !== -1 - }) + // --- ProtonVPN options (location, Secure Core, `config set` settings) --- + // Unlike Mullvad's relay constraints, location and Secure Core aren't + // CLI settings — they're `connect` flags — so the panel keeps them + // itself (persisted in protonOptionsFile) and passes them on every + // connect. The rest are real settings read from `config list`. All of + // it needs a signed-in account. + + readonly property bool protonOptionsAvailable: protonInstalled && protonSignedIn + property bool protonOptionsOpen: false + property bool protonPickerOpen: false + // "" = Proton's own fastest server. + property string protonCountryCode: "" + property bool protonSecureCore: false + // [{name, code}], parsed from `protonvpn countries list` and cached for + // the life of the panel, like mullvadCountries. + property var protonCountries: [] + property bool protonKillSwitch: false + property bool protonAccelerator: false + // "off" | "malware-only" | "malware-ads-trackers" + property string protonNetshield: "off" + + readonly property string protonLocationLabel: { + if (!root.protonCountryCode) return "Fastest" + const code = root.protonCountryCode + const country = root.protonCountries.find(function(c) { return c.code === code }) + return country ? country.name : code + } + + readonly property var protonNetshieldLevels: ["off", "malware-only", "malware-ads-trackers"] + readonly property var protonNetshieldLabels: ({ + "off": "Off", + "malware-only": "Malware", + "malware-ads-trackers": "Malware, ads & trackers" + }) + + // The ProtonVPN CLI reports failures through click, which prefixes the + // message with "Error: " — and, for usage errors, a usage block first — + // so prefer that line over just the first one. + function protonError(text, fallback) { + const m = String(text || "").match(/^Error:\s*(.+)$/m) + return m ? root.shortLine(m[1]) : (root.shortLine(text) || fallback) + } + + // The CLI's exception hook turns *any* uncaught error into a silent + // exit 1 — including ones from background work after the command has + // already done its job (e.g. the location lookup that runs as the + // tunnel drops). So its own success line counts as success even when + // the exit code says otherwise. + function protonSucceeded(exitCode, out, successLine) { + return exitCode === 0 || successLine.test(String(out || "")) } function shortLine(text) { @@ -116,6 +187,7 @@ Item { root.mullvadDetail = "" } VpnExclusivity.mullvadConnected = root.mullvadState === "connected" + root._mullvadKnown = true } // No --json here — ProtonVPN's CLI only prints plain text, so this @@ -130,6 +202,48 @@ Item { const countryMatch = body.match(/^Country:\s*(.+)$/mi) root.protonDetail = serverMatch ? serverMatch[1].trim() : (countryMatch ? countryMatch[1].trim() : "") VpnExclusivity.protonConnected = root.protonState === "connected" + root._protonKnown = true + } + + // `protonvpn info` prints "Account: 'None'" while signed out. + function applyProtonInfo(text) { + const m = String(text || "").match(/^Account:\s*'(.*)'\s*$/mi) + root.protonSignedIn = !!m && m[1] !== "" && m[1] !== "None" + } + + // The CLI reads the password via getpass and the 2FA code via + // click.prompt; with no controlling terminal (setsid) both fall back to + // stdin, so they're written there one per line. A code switches to + // --totp — otherwise the CLI would wait on a security key instead. + function signInProton(username, password, code) { + if (protonSigninProc.running) return false + const user = String(username || "").trim() + if (!user || !password) { + root.protonSigninStatus = "Enter your username and password" + return false + } + const totp = String(code || "").trim() + root.protonSigninStatus = "" + root._protonSigninInput = password + "\n" + (totp ? totp + "\n" : "") + protonSigninProc.command = ["setsid", "-w", "protonvpn", "signin"] + .concat(totp ? ["--totp"] : []).concat([user]) + protonSigninProc.stdinEnabled = true + protonSigninProc.running = true + return true + } + + // stderr also carries getpass's "can't control echo" warnings, so pick + // out the CLI's own "Error: …" line rather than just the first one. + function describeProtonSigninFailure(out, err) { + if (/Invalid two-factor/i.test(out)) return "Invalid 2FA code" + const errorMatch = String(err || "").match(/^Error:\s*(.+)$/m) + if (errorMatch) return root.shortLine(errorMatch[1]) + // stdin ran out at a prompt — the 2FA one, since the password + // always gets a line. + if (/^Aborted!?$/m.test(err)) return /security key/i.test(out) + ? "No security key found — enter a 2FA code instead" + : "Enter your 2FA code" + return "Sign-in failed" } // `nmcli -g GENERAL.STATE,IP4.ADDRESS connection show ` prints @@ -144,6 +258,7 @@ Item { : "disconnected" root.homeWgDetail = root.homeWgState === "connected" && lines[1] ? lines[1].split(" | ")[0] : "" VpnExclusivity.homeWgConnected = root.homeWgState === "connected" + root._homeWgKnown = true } function applyMullvadAutoConnect(text) { @@ -195,6 +310,7 @@ Item { function openMullvadOptions() { root.mullvadOptionsOpen = !root.mullvadOptionsOpen + root.uninstallConfirm = "" if (root.mullvadOptionsOpen) { if (!mullvadAutoConnectGetProc.running) mullvadAutoConnectGetProc.running = true if (!mullvadRelayGetProc.running) mullvadRelayGetProc.running = true @@ -205,7 +321,6 @@ Item { } function openMullvadPicker(target) { - root.mullvadPickerFilter = "" root.mullvadPickerTarget = root.mullvadPickerTarget === target ? "" : target } @@ -255,6 +370,7 @@ Item { protonStatusProc.running = true protonPollWatchdog.restart() } + if (!protonInfoProc.running) protonInfoProc.running = true } function refreshHomeWg() { @@ -301,9 +417,98 @@ Item { if (protonConnectProc.running || protonDisconnectProc.running) return if (root.stopOthers("protonvpn")) { root.pendingConnect = "protonvpn"; return } root.pendingConnect = "" + protonConnectProc.command = ["protonvpn", "connect"] + .concat(root.protonSecureCore ? ["--securecore"] : []) + .concat(root.protonCountryCode ? ["--country", root.protonCountryCode] : []) protonConnectProc.running = true } + // `config list` prints a "Setting Value" table; setting names are + // lowercase-with-dashes, which also skips the header row. + function applyProtonConfig(text) { + const values = {} + for (const line of String(text || "").split("\n")) { + const m = line.match(/^([a-z0-9-]+)\s+(\S+)\s*$/) + if (m) values[m[1]] = m[2] + } + root.protonKillSwitch = !!values["kill-switch"] && values["kill-switch"] !== "off" + root.protonAccelerator = values["vpn-accelerator"] === "on" + root.protonNetshield = values["netshield"] || "off" + } + + // Rows look like "Sweden SE". + function applyProtonCountries(text) { + const countries = [] + for (const line of String(text || "").split("\n")) { + const m = line.match(/^(\S.*?)\s{2,}([A-Z]{2})\s*$/) + if (m) countries.push({ name: m[1].trim(), code: m[2] }) + } + countries.sort(function(a, b) { return a.name.localeCompare(b.name) }) + root.protonCountries = countries + } + + function applyProtonOptionsFile(text) { + try { + const data = JSON.parse(text) + root.protonCountryCode = typeof data.country === "string" ? data.country : "" + root.protonSecureCore = data.secureCore === true + } catch (e) { + // keep the defaults + } + } + + function saveProtonOptions() { + protonOptionsFile.setText(JSON.stringify({ + country: root.protonCountryCode, + secureCore: root.protonSecureCore + })) + } + + function openProtonOptions() { + root.protonOptionsOpen = !root.protonOptionsOpen + root.uninstallConfirm = "" + // Signed out, the options hold only Uninstall — nothing to fetch. + if (root.protonOptionsOpen && root.protonSignedIn) { + if (!protonConfigListProc.running) protonConfigListProc.running = true + if (root.protonCountries.length === 0 && !protonCountriesProc.running) protonCountriesProc.running = true + } else { + root.protonPickerOpen = false + } + } + + // Location and Secure Core only apply at connect time, so while + // connected, re-run connect — the CLI switches the live tunnel over + // rather than needing a disconnect first. + function reconnectProtonIfConnected() { + if (root.protonState === "connected") root.connectProton() + } + + function pickProtonLocation(code) { + root.protonPickerOpen = false + if (code === root.protonCountryCode) return + root.protonCountryCode = code + root.saveProtonOptions() + root.reconnectProtonIfConnected() + } + + function setProtonSecureCore(on) { + root.protonSecureCore = on + root.saveProtonOptions() + root.reconnectProtonIfConnected() + } + + function setProtonConfig(setting, value) { + if (protonConfigSetProc.running) return + protonConfigSetProc.command = ["protonvpn", "config", "set", setting, value] + protonConfigSetProc.running = true + } + + function cycleProtonNetshield() { + const levels = root.protonNetshieldLevels + const next = levels[(levels.indexOf(root.protonNetshield) + 1) % levels.length] + root.setProtonConfig("netshield", next) + } + function connectHomeWg() { if (homeWgConnectProc.running || homeWgDisconnectProc.running) return if (root.stopOthers("homewg")) { root.pendingConnect = "homewg"; return } @@ -328,16 +533,50 @@ Item { homeWgDisconnectProc.running = true } + // Called as you switch a VPN on: if that's about to take other tunnels + // down (stopOthers), or Tailscale (tailscaleDownProc, once connected), + // say which. Done here rather than in stopOthers since that runs twice + // for a deferred connect, and Tailscale is still up for the second one. + function noteSwitchingFrom(keep) { + const names = [] + if (keep !== "mullvad" && root.mullvadState !== "disconnected") names.push("Mullvad") + if (keep !== "protonvpn" && root.protonState !== "disconnected") names.push("ProtonVPN") + if (keep !== "homewg" && root.homeWgState !== "disconnected") names.push("Home (UniFi)") + if (VpnExclusivity.tailscaleConnected) names.push("Tailscale") + if (names.length === 0) return + root.exclusivityNote = "Disconnected " + names.join(" and ") + " — only one VPN at a time." + exclusivityNoteTimer.restart() + } + + // vpn: "mullvad" | "proton"; action: "install" | "uninstall". + function runPackageJob(vpn, action) { + if (vpnPackageProc.running) return + root.uninstallConfirm = "" + root.packageJob = vpn + ":" + action + vpnPackageProc.command = ["bash", Quickshell.shellPath("scripts/VpnPackage.sh"), action, vpn] + vpnPackageProc.running = true + } + function toggleMullvad() { if (mullvadConnectProc.running || mullvadDisconnectProc.running) return if (root.mullvadState === "connected") root.disconnectMullvad() - else root.connectMullvad() + else { + root.noteSwitchingFrom("mullvad") + root.connectMullvad() + } } function toggleProton() { if (protonConnectProc.running || protonDisconnectProc.running) return + if (!root.protonSignedIn) { + root.protonSigninOpen = !root.protonSigninOpen + return + } if (root.protonState === "connected") root.disconnectProton() - else root.connectProton() + else { + root.noteSwitchingFrom("protonvpn") + root.connectProton() + } } // Opens a file chooser for the UniFi client .conf and imports it as @@ -353,7 +592,10 @@ Item { function toggleHomeWg() { if (homeWgConnectProc.running || homeWgDisconnectProc.running) return if (root.homeWgState === "connected") root.disconnectHomeWg() - else root.connectHomeWg() + else { + root.noteSwitchingFrom("homewg") + root.connectHomeWg() + } } // Tailscale fights Mullvad/ProtonVPN/home WireGuard for the default route/DNS, so fire @@ -361,6 +603,24 @@ Item { // 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. + // Lets TailscaleIndicator.qml tell a settled "no VPN connected" (bring + // Tailscale up) from the gap mid-switch between two providers, or + // startup before every provider has been polled (leave it alone). + Binding { + target: VpnExclusivity + property: "vpnStatesKnown" + value: root._mullvadKnown && root._protonKnown && root._homeWgKnown + } + Binding { + target: VpnExclusivity + property: "vpnTransitioning" + value: root.pendingConnect !== "" + || [root.mullvadState, root.protonState, root.homeWgState].some(function(s) { return s === "connecting" || s === "disconnecting" }) + || mullvadConnectProc.running || mullvadDisconnectProc.running + || protonConnectProc.running || protonDisconnectProc.running + || homeWgConnectProc.running || homeWgDisconnectProc.running + } + Process { id: tailscaleDownProc command: ["tailscale", "down"] @@ -378,6 +638,7 @@ Item { onExited: (exitCode, exitStatus) => { root.mullvadInstalled = exitCode === 0 if (root.mullvadInstalled) root.refreshMullvad() + else root._mullvadKnown = true } } @@ -514,6 +775,7 @@ Item { onExited: (exitCode, exitStatus) => { root.protonInstalled = exitCode === 0 if (root.protonInstalled) root.refreshProton() + else root._protonKnown = true } } @@ -529,14 +791,51 @@ Item { } } + Process { + id: protonInfoProc + command: ["protonvpn", "info"] + stdout: StdioCollector { + id: protonInfoCollector + onStreamFinished: root.applyProtonInfo(protonInfoCollector.text) + } + } + + Process { + id: protonSigninProc + stdout: StdioCollector { id: protonSigninOut } + stderr: StdioCollector { id: protonSigninErr } + onStarted: { + protonSigninProc.write(root._protonSigninInput) + root._protonSigninInput = "" + // Closes stdin, so a prompt with nothing left to read aborts + // instead of hanging. + protonSigninProc.stdinEnabled = false + } + onExited: (exitCode, exitStatus) => { + root._protonSigninInput = "" + if (exitCode === 0) { + const m = protonSigninOut.text.match(/signed in as '(.*)'/i) + root.protonSignedIn = true + root.protonActionStatus = m ? "Signed in as " + m[1] : "Signed in" + protonActionStatusTimer.restart() + // The form was opened by pressing connect, so finish the job. + root.protonSigninOpen = false + root.noteSwitchingFrom("protonvpn") + root.connectProton() + } else { + root.protonSigninStatus = root.describeProtonSigninFailure(protonSigninOut.text, protonSigninErr.text) + } + root.refreshProton() + } + } + Process { id: protonConnectProc - command: ["protonvpn", "connect"] - stdout: StdioCollector {} + stdout: StdioCollector { id: protonConnectOut } stderr: StdioCollector { id: protonConnectErr } onExited: (exitCode, exitStatus) => { - if (exitCode !== 0) { - root.protonActionStatus = root.shortLine(protonConnectErr.text || "Connect failed") + if (!root.protonSucceeded(exitCode, protonConnectOut.text, /^Connected to /m)) { + root.protonActionStatus = root.protonError(protonConnectErr.text, "Connect failed") protonActionStatusTimer.restart() } else if (!tailscaleDownProc.running) { tailscaleDownProc.running = true @@ -548,11 +847,11 @@ Item { Process { id: protonDisconnectProc command: ["protonvpn", "disconnect"] - stdout: StdioCollector {} + stdout: StdioCollector { id: protonDisconnectOut } stderr: StdioCollector { id: protonDisconnectErr } onExited: (exitCode, exitStatus) => { - if (exitCode !== 0) { - root.protonActionStatus = root.shortLine(protonDisconnectErr.text || "Disconnect failed") + if (!root.protonSucceeded(exitCode, protonDisconnectOut.text, /^Disconnected\.$/m)) { + root.protonActionStatus = root.protonError(protonDisconnectErr.text, "Disconnect failed") protonActionStatusTimer.restart() } else { root.protonState = "disconnected" @@ -562,6 +861,48 @@ Item { } } + // --- ProtonVPN options: only queried while the options are open (see + // root.openProtonOptions), like Mullvad's. + + Process { + id: protonConfigListProc + command: ["protonvpn", "config", "list"] + stdout: StdioCollector { + id: protonConfigListCollector + onStreamFinished: root.applyProtonConfig(protonConfigListCollector.text) + } + } + + Process { + id: protonConfigSetProc + stdout: StdioCollector {} + stderr: StdioCollector { id: protonConfigSetErr } + onExited: (exitCode, exitStatus) => { + if (exitCode !== 0) { + root.protonActionStatus = root.protonError(protonConfigSetErr.text, "Setting change failed") + protonActionStatusTimer.restart() + } + if (!protonConfigListProc.running) protonConfigListProc.running = true + } + } + + Process { + id: protonCountriesProc + command: ["protonvpn", "countries", "list"] + stdout: StdioCollector { + id: protonCountriesCollector + onStreamFinished: root.applyProtonCountries(protonCountriesCollector.text) + } + } + + // Read once at startup; missing just means the defaults (fastest + // server, no Secure Core). + FileView { + id: protonOptionsFile + path: Quickshell.env("HOME") + "/.cache/quickshell/protonvpn-options.json" + onLoaded: root.applyProtonOptionsFile(text()) + } + Timer { id: protonPollWatchdog interval: 15000 @@ -638,6 +979,56 @@ Item { } } + // --- Install / Uninstall --------------------------------------------- + + // pkexec's password dialog takes focus, which clears the focus grab and + // closes the panel — so, like homeWgImportProc, this reopens it when + // done to show the result. + Process { + id: vpnPackageProc + stdout: StdioCollector {} + stderr: StdioCollector { id: vpnPackageErr } + onExited: (exitCode, exitStatus) => { + const job = root.packageJob.split(":") + const vpn = job[0] + const installing = job[1] === "install" + root.packageJob = "" + + // 3 = password dialog dismissed — nothing to report. + let message = "" + if (exitCode === 0) { + message = installing + ? (vpn === "proton" ? "Installed — switch on to sign in" : "Installed") + : "Uninstalled" + } else if (exitCode !== 3) { + message = root.shortLine(vpnPackageErr.text) || (installing ? "Install failed" : "Uninstall failed") + } + + if (vpn === "mullvad") { + if (exitCode === 0 && !installing) { + root.mullvadOptionsOpen = false + root.mullvadState = "disconnected" + VpnExclusivity.mullvadConnected = false + } + if (!mullvadWhichProc.running) mullvadWhichProc.running = true + root.mullvadActionStatus = message + if (message !== "") mullvadActionStatusTimer.restart() + } else { + if (exitCode === 0 && !installing) { + root.protonOptionsOpen = false + root.protonState = "disconnected" + VpnExclusivity.protonConnected = false + } + if (!protonWhichProc.running) protonWhichProc.running = true + root.protonActionStatus = message + if (message !== "") protonActionStatusTimer.restart() + } + + panel.visible = true + grab.active = true + } + } + // --- Shared timers ----------------------------------------------------- Timer { @@ -670,6 +1061,13 @@ Item { onTriggered: root.homeWgActionStatus = "" } + Timer { + id: exclusivityNoteTimer + interval: 6000 + repeat: false + onTriggered: root.exclusivityNote = "" + } + // --- Bar icon ------------------------------------------------------------ // A fixed, wallpaper-independent green rather than Colors.primary — the @@ -679,6 +1077,16 @@ Item { // low-contrast depending on the current pywal palette. readonly property color connectedBadgeColor: "#3ddc84" + // Space between an expanded options card's content and its outline, + // on every side. Vertically it's added inside the card (so two + // expanded cards' outlines can't meet); sideways the outline just sits + // further out, since nothing neighbours a card left or right. + readonly property int cardPadding: 8 + // Options rows are inset like ProviderRow's own content (its + // rowContent margins), so their switches line up under the row's + // chevron instead of running to the card's clipped edge. + readonly property int optionsInset: 6 + Text { id: label anchors.centerIn: parent @@ -768,7 +1176,7 @@ Item { // 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 { + component ProviderRow: Item { id: provRow property string label: "" property string subIcon: "shield" @@ -776,19 +1184,16 @@ Item { property string state: "disconnected" property bool installed: true property string missingText: "Not installed" + // Status line while installed but not connected. + property string idleText: "Disconnected" // When true, shows a chevron that expands a provider-specific - // options section below this row (used for Mullvad's advanced - // options; ProtonVPN's row leaves this false). + // options section below this row. property bool expandable: false property bool expanded: false signal toggle() signal expandToggle() 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 @@ -829,7 +1234,7 @@ Item { : provRow.state === "connecting" ? "Connecting…" : provRow.state === "disconnecting" ? "Disconnecting…" : provRow.state === "connected" ? (provRow.detailText || "Connected") - : "Disconnected" + : provRow.idleText elide: Text.ElideRight font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSizeSmall @@ -864,15 +1269,16 @@ Item { } } - // A single clickable "Location: Sweden ›" row inside the Mullvad - // options section — used for both the exit location and (when multihop - // is on) the entry location. Clicking routes back to root, which opens - // the shared country picker below rather than each field owning one. + // A single clickable "Location: Sweden ›" row inside an options section + // — Mullvad's exit/entry locations, ProtonVPN's location and NetShield. + // Clicking routes back to root, which opens a CountryPicker (or, for + // NetShield, steps to the next value). component LocationField: Item { id: locField property string fieldLabel: "" property string valueText: "Any" property bool open: false + property string indicator: open ? "expand_less" : "chevron_right" signal clicked() width: parent ? parent.width : 0 implicitHeight: locRow.implicitHeight @@ -899,7 +1305,7 @@ Item { color: Colors.foreground } Text { - text: locField.open ? "expand_less" : "chevron_right" + text: locField.indicator font.family: Appearance.iconFontFamilyRounded font.variableAxes: Appearance.iconFontAxes font.pixelSize: 14 @@ -913,6 +1319,200 @@ Item { } } + // A small "icon + label" text link — Home's Add/Replace config and each + // provider's Install / Uninstall. Goes inside a LinkRow. + component ActionLink: Item { + id: link + property string icon: "" + property string text: "" + property bool busy: false + property color tint: Colors.primary + signal clicked() + implicitWidth: linkRow.implicitWidth + implicitHeight: linkRow.implicitHeight + opacity: link.busy ? 0.5 : 1 + + Row { + id: linkRow + spacing: 4 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: link.icon + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 13 + color: linkMouse.containsMouse ? Colors.foreground : link.tint + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: link.text + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + font.underline: linkMouse.containsMouse + color: linkMouse.containsMouse ? Colors.foreground : link.tint + } + } + + MouseArea { + id: linkMouse + anchors.fill: parent + anchors.margins: -4 + enabled: !link.busy + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: link.clicked() + } + } + + // One or more ActionLinks side by side. Indented by default to line up + // with ProviderRow's name rather than its icon; links inside an options + // section use root.optionsInset instead, to line up with its rows. + component LinkRow: Item { + default property alias links: linkRowContent.data + property int indent: 34 + width: parent ? parent.width : 0 + implicitHeight: linkRowContent.implicitHeight + 8 + + Row { + id: linkRowContent + anchors.left: parent.left + anchors.leftMargin: parent.indent + anchors.verticalCenter: parent.verticalCenter + spacing: 14 + } + } + + // A labelled on/off switch row inside an options section. + component OptionToggle: RowLayout { + id: optToggle + property string text: "" + property bool checked: false + property bool busy: false + signal toggled() + width: parent ? parent.width : 0 + + Text { + Layout.fillWidth: true + text: optToggle.text + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.foreground + } + ToggleSwitch { + checked: optToggle.checked + busy: optToggle.busy + onToggled: optToggle.toggled() + } + } + + // Filter box plus a capped, scrollable country list, with an "any" + // entry always pinned first. The filter is the picker's own and starts + // empty each time it opens. + component CountryPicker: Column { + id: picker + // [{name, code}] + property var countries: [] + property string anyLabel: "Any" + property string anyCode: "any" + property string filter: "" + signal picked(string code) + width: parent ? parent.width : 0 + spacing: 6 + + readonly property var _filtered: { + const f = picker.filter.trim().toLowerCase() + if (!f) return picker.countries + return picker.countries.filter(function(c) { + return c.name.toLowerCase().indexOf(f) !== -1 || c.code.toLowerCase().indexOf(f) !== -1 + }) + } + + onVisibleChanged: if (visible) { + picker.filter = "" + filterInput.forceActiveFocus() + } + + Rectangle { + width: parent.width + implicitHeight: 24 + radius: 0 + color: Colors.surface + border.color: Colors.muted + border.width: 1 + + Text { + anchors.fill: parent + anchors.margins: 6 + visible: filterInput.text.length === 0 + text: "Search country…" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + TextInput { + id: filterInput + anchors.fill: parent + anchors.margins: 6 + color: Colors.foreground + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + clip: true + text: picker.filter + onTextChanged: picker.filter = text + } + } + + Flickable { + id: pickerFlick + width: parent.width + height: Math.min(pickerColumn.implicitHeight, 150) + contentWidth: width + contentHeight: pickerColumn.implicitHeight + clip: true + boundsBehavior: Flickable.StopAtBounds + + Column { + id: pickerColumn + width: pickerFlick.width + spacing: 1 + + Repeater { + model: [{ name: picker.anyLabel, code: picker.anyCode }].concat(picker._filtered) + 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: picker.picked(modelData.code) + } + } + } + } + } + } + // A failed connect/disconnect/settings-change message, shown directly // under the provider row it came from rather than in one shared line // for both providers — keeps an error attributed to whichever CLI it @@ -928,6 +1528,50 @@ Item { wrapMode: Text.WordWrap } + // A bordered single-line input with placeholder text, styled like the + // Mullvad country filter — used by the ProtonVPN sign-in card. + component InputField: Rectangle { + id: inputField + property string placeholder: "" + property alias text: fieldInput.text + property alias echoMode: fieldInput.echoMode + property alias input: fieldInput + // Tab/Shift+Tab targets, as other InputFields. + property Item nextField: null + property Item prevField: null + signal accepted() + width: parent ? parent.width : 0 + implicitHeight: 24 + radius: 0 + color: Colors.surface + border.color: fieldInput.activeFocus ? Colors.foreground : Colors.muted + border.width: 1 + opacity: inputField.enabled ? 1 : 0.5 + + Text { + anchors.fill: parent + anchors.margins: 6 + visible: fieldInput.text.length === 0 + text: inputField.placeholder + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + color: Colors.muted + } + + TextInput { + id: fieldInput + anchors.fill: parent + anchors.margins: 6 + color: Colors.foreground + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + clip: true + KeyNavigation.tab: inputField.nextField ? inputField.nextField.input : null + KeyNavigation.backtab: inputField.prevField ? inputField.prevField.input : null + onAccepted: inputField.accepted() + } + } + // --- Panel ----------------------------------------------------------- PopupWindow { @@ -951,7 +1595,14 @@ Item { root.refresh() } else { root.mullvadPickerTarget = "" - root.mullvadPickerFilter = "" + root.protonPickerOpen = false + // Don't leave a typed password sitting in the closed panel. + protonPasswordField.text = "" + protonCodeField.text = "" + root.protonSigninOpen = false + root.protonSigninStatus = "" + root.exclusivityNote = "" + root.uninstallConfirm = "" } } @@ -1009,8 +1660,8 @@ Item { Column { width: parent.width - // Room for the cards' outlines (drawn 3px outside them) - // so two expanded ones don't touch. + // Room for the cards' outlines (drawn 3px outside them, + // vertically) so two expanded ones don't touch. spacing: 8 // Same collapsible card as Mullvad below — framed only @@ -1018,17 +1669,19 @@ Item { Item { readonly property bool _framed: root.homeWgOptionsOpen && root.homeWgInstalled width: parent.width - implicitHeight: homeWgSectionColumn.implicitHeight + implicitHeight: homeWgSectionColumn.implicitHeight + (_framed ? (root.cardPadding - 3) * 2 : 0) 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. + // Outline sits cardPadding away from the content on + // every side (see root.cardPadding). Rectangle { anchors.fill: parent - anchors.margins: -3 + anchors.topMargin: -3 + anchors.bottomMargin: -3 + anchors.leftMargin: -root.cardPadding + anchors.rightMargin: -root.cardPadding radius: 0 color: "transparent" border.color: Colors.foreground @@ -1045,6 +1698,10 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top + anchors.topMargin: parent.parent._framed ? root.cardPadding - 3 : 0 + Behavior on anchors.topMargin { + NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic } + } spacing: 8 ProviderRow { @@ -1068,46 +1725,15 @@ Item { // 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 + LinkRow { 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 + ActionLink { + icon: root.homeWgInstalled ? "sync" : "upload_file" + text: root.homeWgImporting ? "Choosing file…" + : root.homeWgInstalled ? "Replace config" : "Add config" + busy: root.homeWgImporting onClicked: root.importHomeWgConfig() } } @@ -1128,6 +1754,259 @@ Item { } } + // Same collapsible card as Mullvad below, framed only + // while ProtonVPN's options are expanded. + Item { + readonly property bool _framed: root.protonOptionsOpen && root.protonInstalled + width: parent.width + implicitHeight: protonSectionColumn.implicitHeight + (_framed ? (root.cardPadding - 3) * 2 : 0) + Behavior on implicitHeight { + NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic } + } + + Rectangle { + anchors.fill: parent + anchors.topMargin: -3 + anchors.bottomMargin: -3 + anchors.leftMargin: -root.cardPadding + anchors.rightMargin: -root.cardPadding + 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: protonSectionColumn + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.topMargin: parent.parent._framed ? root.cardPadding - 3 : 0 + Behavior on anchors.topMargin { + NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic } + } + spacing: 8 + + ProviderRow { + label: "ProtonVPN" + subIcon: "gpp_good" + state: root.protonState + detailText: root.protonDetail + installed: root.protonInstalled + idleText: root.protonSignedIn ? "Disconnected" : "Signed out" + expandable: root.protonInstalled + expanded: root.protonOptionsOpen + onToggle: root.toggleProton() + onExpandToggle: root.openProtonOptions() + } + + StatusLine { message: root.protonActionStatus } + + // Location and Secure Core (connect flags), + // then the CLI's own settings. + Column { + x: root.optionsInset + width: parent.width - root.optionsInset * 2 + visible: root.protonOptionsOpen && root.protonOptionsAvailable + spacing: 8 + + LocationField { + fieldLabel: "Location" + valueText: root.protonLocationLabel + open: root.protonPickerOpen + onClicked: root.protonPickerOpen = !root.protonPickerOpen + } + + CountryPicker { + visible: root.protonPickerOpen + countries: root.protonCountries + anyLabel: "Fastest" + anyCode: "" + onPicked: code => root.pickProtonLocation(code) + } + + OptionToggle { + text: "Secure Core" + checked: root.protonSecureCore + busy: protonConnectProc.running + onToggled: root.setProtonSecureCore(!root.protonSecureCore) + } + + OptionToggle { + text: "Kill switch" + checked: root.protonKillSwitch + busy: protonConfigSetProc.running + onToggled: root.setProtonConfig("kill-switch", root.protonKillSwitch ? "off" : "standard") + } + + OptionToggle { + text: "VPN Accelerator" + checked: root.protonAccelerator + busy: protonConfigSetProc.running + onToggled: root.setProtonConfig("vpn-accelerator", root.protonAccelerator ? "off" : "on") + } + + // Three levels, so it steps through them + // on click rather than being a switch. + LocationField { + fieldLabel: "NetShield" + valueText: root.protonNetshieldLabels[root.protonNetshield] || root.protonNetshield + indicator: "unfold_more" + enabled: !protonConfigSetProc.running + opacity: enabled ? 1 : 0.5 + onClicked: root.cycleProtonNetshield() + } + } + + // Shown outright while not installed, like Home's + // Add config. + LinkRow { + visible: !root.protonInstalled + + ActionLink { + icon: "download" + text: root.packageJob === "proton:install" ? "Installing…" : "Install ProtonVPN" + busy: root.packageJob !== "" + onClicked: root.runPackageJob("proton", "install") + } + } + + // Two clicks: Uninstall, then Confirm uninstall. + LinkRow { + visible: root.protonOptionsOpen && root.protonInstalled + indent: root.optionsInset + + ActionLink { + visible: root.uninstallConfirm !== "proton" + icon: "delete" + text: root.packageJob === "proton:uninstall" ? "Uninstalling…" : "Uninstall" + busy: root.packageJob !== "" + onClicked: root.uninstallConfirm = "proton" + } + ActionLink { + visible: root.uninstallConfirm === "proton" + icon: "delete" + text: "Confirm uninstall" + tint: Colors.urgent + onClicked: root.runPackageJob("proton", "uninstall") + } + ActionLink { + visible: root.uninstallConfirm === "proton" + icon: "close" + text: "Cancel" + onClicked: root.uninstallConfirm = "" + } + } + } + } + } + + // ProtonVPN sign-in, directly under the ProtonVPN row. + // Opened by pressing its connect toggle while signed out + // (see root.toggleProton); connects once signed in. + Column { + id: protonSigninCard + width: parent.width + visible: root.protonInstalled && !root.protonSignedIn && root.protonSigninOpen + spacing: 6 + + // Lands on the first empty field — the username is + // kept between attempts, the password isn't. + onVisibleChanged: if (visible) { + (protonUserField.text ? protonPasswordField : protonUserField).input.forceActiveFocus() + } + + // Fields are kept when the attempt never started (e.g. + // empty username), cleared once they've been handed off. + function submit() { + if (!root.signInProton(protonUserField.text, protonPasswordField.text, protonCodeField.text)) return + protonPasswordField.text = "" + protonCodeField.text = "" + } + + Text { + width: parent.width + text: "Sign in to ProtonVPN" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSize + font.bold: true + color: Colors.foreground + } + + InputField { + id: protonUserField + placeholder: "Username or email" + enabled: !root.protonSigningIn + nextField: protonPasswordField + onAccepted: protonPasswordField.input.forceActiveFocus() + } + + InputField { + id: protonPasswordField + placeholder: "Password" + echoMode: TextInput.Password + enabled: !root.protonSigningIn + nextField: protonCodeField + prevField: protonUserField + onAccepted: protonSigninCard.submit() + } + + InputField { + id: protonCodeField + placeholder: "2FA code (if enabled)" + enabled: !root.protonSigningIn + prevField: protonPasswordField + onAccepted: protonSigninCard.submit() + } + + StatusLine { message: root.protonSigninStatus } + + Item { + width: parent.width + implicitHeight: signinRow.implicitHeight + 4 + + Row { + id: signinRow + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 4 + opacity: root.protonSigningIn ? 0.5 : 1 + + Text { + anchors.verticalCenter: parent.verticalCenter + text: "login" + font.family: Appearance.iconFontFamilyRounded + font.variableAxes: Appearance.iconFontAxes + font.pixelSize: 13 + color: signinMouse.containsMouse ? Colors.foreground : Colors.primary + } + Text { + anchors.verticalCenter: parent.verticalCenter + text: root.protonSigningIn ? "Signing in…" : "Sign in" + font.family: Appearance.fontFamily + font.pixelSize: Appearance.fontSizeSmall + font.underline: signinMouse.containsMouse + color: signinMouse.containsMouse ? Colors.foreground : Colors.primary + } + } + + MouseArea { + id: signinMouse + anchors.fill: signinRow + anchors.margins: -4 + enabled: !root.protonSigningIn + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: protonSigninCard.submit() + } + } + } + // Mullvad's connect row and its advanced options share a // single bordered card — the border wraps the whole // section (row, status line, and options together) and @@ -1138,7 +2017,7 @@ Item { Item { readonly property bool _framed: root.mullvadOptionsOpen && root.mullvadInstalled width: parent.width - implicitHeight: mullvadSectionColumn.implicitHeight + implicitHeight: mullvadSectionColumn.implicitHeight + (_framed ? (root.cardPadding - 3) * 2 : 0) // Without this, the card snapped to its new size // instantly while the popup around it was still // animating open, so the border visibly lagged @@ -1148,12 +2027,14 @@ Item { 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. + // Outline sits cardPadding away from the content on + // every side (see root.cardPadding). Rectangle { anchors.fill: parent - anchors.margins: -3 + anchors.topMargin: -3 + anchors.bottomMargin: -3 + anchors.leftMargin: -root.cardPadding + anchors.rightMargin: -root.cardPadding radius: 0 color: "transparent" border.color: Colors.foreground @@ -1170,6 +2051,10 @@ Item { anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top + anchors.topMargin: parent.parent._framed ? root.cardPadding - 3 : 0 + Behavior on anchors.topMargin { + NumberAnimation { duration: Appearance.animationDuration; easing.type: Easing.OutCubic } + } spacing: 8 ProviderRow { @@ -1190,40 +2075,23 @@ Item { // relay location, multihop. Column { id: mullvadOptionsColumn - width: parent.width + x: root.optionsInset + width: parent.width - root.optionsInset * 2 visible: root.mullvadOptionsOpen && root.mullvadInstalled spacing: 8 - RowLayout { - width: parent.width - Text { - Layout.fillWidth: true - text: "Auto-connect" - font.family: Appearance.fontFamily - font.pixelSize: Appearance.fontSizeSmall - color: Colors.foreground - } - ToggleSwitch { - checked: root.mullvadAutoConnect - busy: mullvadAutoConnectSetProc.running - onToggled: root.setMullvadAutoConnect(!root.mullvadAutoConnect) - } + OptionToggle { + text: "Auto-connect" + checked: root.mullvadAutoConnect + busy: mullvadAutoConnectSetProc.running + onToggled: root.setMullvadAutoConnect(!root.mullvadAutoConnect) } - RowLayout { - width: parent.width - Text { - Layout.fillWidth: true - text: "Multihop" - font.family: Appearance.fontFamily - font.pixelSize: Appearance.fontSizeSmall - color: Colors.foreground - } - ToggleSwitch { - checked: root.mullvadMultihop - busy: mullvadRelaySetProc.running - onToggled: root.setMullvadMultihop(!root.mullvadMultihop) - } + OptionToggle { + text: "Multihop" + checked: root.mullvadMultihop + busy: mullvadRelaySetProc.running + onToggled: root.setMullvadMultihop(!root.mullvadMultihop) } LocationField { @@ -1241,114 +2109,63 @@ Item { 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 + // Shared by both LocationFields — opens for + // whichever was last clicked. + CountryPicker { visible: root.mullvadPickerTarget !== "" - spacing: 6 + countries: root.mullvadCountries + onPicked: code => root.pickMullvadLocation(code) + } + } - Rectangle { - width: parent.width - implicitHeight: 24 - radius: 0 - color: Colors.surface - border.color: Colors.muted - border.width: 1 + // Shown outright while not installed, like Home's + // Add config. + LinkRow { + visible: !root.mullvadInstalled - 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) - } - } - } - } + ActionLink { + icon: "download" + text: root.packageJob === "mullvad:install" ? "Installing…" : "Install Mullvad" + busy: root.packageJob !== "" + onClicked: root.runPackageJob("mullvad", "install") } } + + // Two clicks: Uninstall, then Confirm uninstall. + LinkRow { + visible: root.mullvadOptionsOpen && root.mullvadInstalled + indent: root.optionsInset + + ActionLink { + visible: root.uninstallConfirm !== "mullvad" + icon: "delete" + text: root.packageJob === "mullvad:uninstall" ? "Uninstalling…" : "Uninstall" + busy: root.packageJob !== "" + onClicked: root.uninstallConfirm = "mullvad" + } + ActionLink { + visible: root.uninstallConfirm === "mullvad" + icon: "delete" + text: "Confirm uninstall" + tint: Colors.urgent + onClicked: root.runPackageJob("mullvad", "uninstall") + } + ActionLink { + visible: root.uninstallConfirm === "mullvad" + icon: "close" + text: "Cancel" + onClicked: root.uninstallConfirm = "" + } } } } } - - ProviderRow { - label: "ProtonVPN" - subIcon: "gpp_good" - state: root.protonState - detailText: root.protonDetail - installed: root.protonInstalled - onToggle: root.toggleProton() - } - - StatusLine { message: root.protonActionStatus } } Text { width: parent.width - text: "Connecting one disconnects the others — only one VPN tunnel at a time." + visible: root.exclusivityNote !== "" + text: root.exclusivityNote font.family: Appearance.fontFamily font.pixelSize: Appearance.fontSizeSmall color: Colors.muted diff --git a/dotfiles/.config/quickshell/scripts/VpnPackage.sh b/dotfiles/.config/quickshell/scripts/VpnPackage.sh new file mode 100755 index 0000000..91bcd04 --- /dev/null +++ b/dotfiles/.config/quickshell/scripts/VpnPackage.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Installs or uninstalls one of the VPN clients the bar's VPN panel drives +# (VpnIndicator.qml's Install / Uninstall links), along with its system +# service. Both packages are in Arch's official repos. +# +# Usage: ./VpnPackage.sh install|uninstall mullvad|proton +# +# This runs from the bar with no terminal to type a sudo password into, so +# the root part goes through pkexec (the polkit agent's password dialog), +# same as update-system.sh's Ghostty install. Exits 3 if that dialog is +# dismissed. +# +# Services: proton-vpn-daemon's own pacman hooks enable proton.VPN.service +# on install and disable it on removal. Mullvad's package only prints a +# reminder, so its daemon is enabled/disabled here. + +set -euo pipefail + +action="${1:-}" +vpn="${2:-}" + +case "$vpn" in + mullvad) pkg=mullvad-vpn-daemon ;; + proton) pkg=proton-vpn-cli ;; + *) echo "Usage: $0 install|uninstall mullvad|proton" >&2; exit 1 ;; +esac + +case "$action" in + install) + root_cmd="pacman -S --needed --noconfirm $pkg" + if [ "$vpn" = mullvad ]; then + root_cmd+=" && systemctl enable --now mullvad-daemon.service" + fi + ;; + uninstall) + # Take the tunnel down cleanly first, rather than have it pulled out + # from under the network by the service stopping. For Mullvad, also + # turn lockdown mode off — otherwise its block-everything firewall + # rules can outlive the daemon. + if [ "$vpn" = mullvad ] && command -v mullvad &> /dev/null; then + mullvad lockdown-mode set off &> /dev/null || true + mullvad disconnect --wait &> /dev/null || true + elif [ "$vpn" = proton ] && command -v protonvpn &> /dev/null; then + protonvpn disconnect &> /dev/null || true + fi + root_cmd="pacman -Rns --noconfirm $pkg" + if [ "$vpn" = mullvad ]; then + root_cmd="systemctl disable --now mullvad-daemon.service mullvad-early-boot-blocking.service &> /dev/null; $root_cmd" + fi + ;; + *) echo "Usage: $0 install|uninstall mullvad|proton" >&2; exit 1 ;; +esac + +rc=0 +pkexec bash -c "$root_cmd" || rc=$? +# 126: the password dialog was dismissed. +if [ $rc -eq 126 ]; then echo "Cancelled" >&2; exit 3; fi +exit $rc