shell: VPN quick toggle in the right panel
Deliberate, never automatic. The autonomous gate it replaces recycled the tunnel 652 times in 90 minutes and could not be switched off, because 'nmcli connection down' fired the dispatcher that brought it back up. Reads NM for state rather than tracking a bool. No privilege needed: polkit already lets the seat user activate a system connection. Registered in the ii-phone chooser as well as ii-base, or the phone overlay wins and the delegate never appears.
This commit is contained in:
parent
15af38ae36
commit
0b57daac06
5 changed files with 150 additions and 1 deletions
|
|
@ -0,0 +1,108 @@
|
||||||
|
import QtQuick
|
||||||
|
import qs.services
|
||||||
|
import qs.modules.common
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A WireGuard tunnel, toggled deliberately.
|
||||||
|
*
|
||||||
|
* DELIBERATE, NEVER AUTOMATIC — and that is the design, not a limitation.
|
||||||
|
* An autonomous gate held this tunnel on 2026-07-30: it recycled 652 times in
|
||||||
|
* 90 minutes, through doze tiers whose contract is "network fetchers stopped",
|
||||||
|
* and the user could not switch it off because `nmcli connection down` fired
|
||||||
|
* the NM dispatcher that immediately brought it back up. A control surface the
|
||||||
|
* owner cannot overrule is not a control surface.
|
||||||
|
*
|
||||||
|
* So there is no daemon, no timer and no dispatcher hook deciding this. The
|
||||||
|
* toggle is the whole of it. If tunnel posture later becomes something the
|
||||||
|
* device decides for itself, it belongs in sessiond's state machine as an
|
||||||
|
* Action (DEVICE-STATE-MACHINE §12) and reachable as a verb (doctrine §13) —
|
||||||
|
* not as another actor off to the side.
|
||||||
|
*
|
||||||
|
* No privilege needed: polkit already lets the seat user activate a system
|
||||||
|
* connection, verified on device.
|
||||||
|
*/
|
||||||
|
QuickToggleModel {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
/// The NetworkManager connection this drives. A property rather than a
|
||||||
|
/// constant so a second profile does not need a second component.
|
||||||
|
property string connectionName: "wiuf"
|
||||||
|
|
||||||
|
/// True while an up/down is in flight, so a double tap cannot race itself.
|
||||||
|
property bool busy: false
|
||||||
|
|
||||||
|
name: Translation.tr("Wiuf VPN")
|
||||||
|
icon: root.toggled ? "vpn_lock" : "vpn_key"
|
||||||
|
statusText: root.busy ? Translation.tr("…") : (root.toggled ? Translation.tr("On") : Translation.tr("Off"))
|
||||||
|
tooltipText: root.toggled
|
||||||
|
? Translation.tr("Wiuf VPN — 10.10.0.0/16 routed over the tunnel")
|
||||||
|
: Translation.tr("Wiuf VPN — off")
|
||||||
|
|
||||||
|
mainAction: () => {
|
||||||
|
if (root.busy)
|
||||||
|
return;
|
||||||
|
root.busy = true;
|
||||||
|
if (root.toggled)
|
||||||
|
downProc.running = true;
|
||||||
|
else
|
||||||
|
upProc.running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notify(body) {
|
||||||
|
Quickshell.execDetached(["notify-send", Translation.tr("Wiuf VPN"), body, "-a", "Shell"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: upProc
|
||||||
|
command: ["nmcli", "connection", "up", root.connectionName]
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.busy = false;
|
||||||
|
refreshProc.running = true;
|
||||||
|
if (exitCode !== 0)
|
||||||
|
root.notify(Translation.tr("Could not connect."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: downProc
|
||||||
|
command: ["nmcli", "connection", "down", root.connectionName]
|
||||||
|
onExited: (exitCode, exitStatus) => {
|
||||||
|
root.busy = false;
|
||||||
|
refreshProc.running = true;
|
||||||
|
if (exitCode !== 0)
|
||||||
|
root.notify(Translation.tr("Could not disconnect."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read NM rather than tracking a local bool: the connection can also be
|
||||||
|
// brought up or down from nmcli, from Settings, or by NM itself, and a
|
||||||
|
// shadow copy that disagrees with the owning subsystem is the failure
|
||||||
|
// doctrine §4 names ("never a hand-tracked bool").
|
||||||
|
Process {
|
||||||
|
id: refreshProc
|
||||||
|
running: true
|
||||||
|
command: ["bash", "-c", `nmcli -t -f NAME connection show --active | grep -qx '${root.connectionName}' && echo up || echo down`]
|
||||||
|
stdout: StdioCollector {
|
||||||
|
id: stateCollector
|
||||||
|
onStreamFinished: {
|
||||||
|
if (stateCollector.text.length === 0)
|
||||||
|
return;
|
||||||
|
root.toggled = stateCollector.text.trim() === "up";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cheap resync for changes made outside the panel. One nmcli call every
|
||||||
|
// 15 s while the shell runs; deliberately not a decision loop.
|
||||||
|
Timer {
|
||||||
|
interval: 15000
|
||||||
|
running: true
|
||||||
|
repeat: true
|
||||||
|
onTriggered: {
|
||||||
|
if (!root.busy)
|
||||||
|
refreshProc.running = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -265,6 +265,20 @@ DelegateChooser {
|
||||||
cellSize: modelData.size
|
cellSize: modelData.size
|
||||||
} }
|
} }
|
||||||
|
|
||||||
|
DelegateChoice { roleValue: "vpn"; AndroidVpnToggle {
|
||||||
|
required property int index
|
||||||
|
required property var modelData
|
||||||
|
buttonIndex: root.startingIndex + index
|
||||||
|
buttonData: modelData
|
||||||
|
editMode: root.editMode
|
||||||
|
dragIndex: root.dragIndex
|
||||||
|
expandedSize: modelData.size > 1
|
||||||
|
baseCellWidth: root.baseCellWidth
|
||||||
|
baseCellHeight: root.baseCellHeight
|
||||||
|
cellSpacing: root.spacing
|
||||||
|
cellSize: modelData.size
|
||||||
|
} }
|
||||||
|
|
||||||
DelegateChoice { roleValue: "screenSnip"; AndroidScreenSnipToggle {
|
DelegateChoice { roleValue: "screenSnip"; AndroidScreenSnipToggle {
|
||||||
required property int index
|
required property int index
|
||||||
required property var modelData
|
required property var modelData
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import qs.modules.common
|
||||||
|
import qs.modules.common.models.quickToggles
|
||||||
|
import qs.modules.common.widgets
|
||||||
|
import qs.services
|
||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Io
|
||||||
|
|
||||||
|
AndroidQuickToggleButton {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
toggleModel: VpnToggle {}
|
||||||
|
}
|
||||||
|
|
@ -295,6 +295,20 @@ DelegateChooser {
|
||||||
cellSize: modelData.size
|
cellSize: modelData.size
|
||||||
} }
|
} }
|
||||||
|
|
||||||
|
DelegateChoice { roleValue: "vpn"; AndroidVpnToggle {
|
||||||
|
required property int index
|
||||||
|
required property var modelData
|
||||||
|
buttonIndex: root.startingIndex + index
|
||||||
|
buttonData: modelData
|
||||||
|
editMode: root.editMode
|
||||||
|
dragIndex: root.dragIndex
|
||||||
|
expandedSize: modelData.size > 1
|
||||||
|
baseCellWidth: root.baseCellWidth
|
||||||
|
baseCellHeight: root.baseCellHeight
|
||||||
|
cellSpacing: root.spacing
|
||||||
|
cellSize: modelData.size
|
||||||
|
} }
|
||||||
|
|
||||||
DelegateChoice { roleValue: "screenSnip"; AndroidScreenSnipToggle {
|
DelegateChoice { roleValue: "screenSnip"; AndroidScreenSnipToggle {
|
||||||
required property int index
|
required property int index
|
||||||
required property var modelData
|
required property var modelData
|
||||||
|
|
|
||||||
|
|
@ -815,7 +815,7 @@ Singleton {
|
||||||
property bool leftAlignApps: false
|
property bool leftAlignApps: false
|
||||||
}
|
}
|
||||||
property JsonObject actionCenter: JsonObject {
|
property JsonObject actionCenter: JsonObject {
|
||||||
property list<string> toggles: [ "network", "bluetooth", "easyEffects", "powerProfile", "idleInhibitor", "nightLight", "darkMode", "antiFlashbang", "cloudflareWarp", "mic", "musicRecognition", "notifications", "onScreenKeyboard", "gameMode", "screenSnip", "colorPicker" ]
|
property list<string> toggles: [ "network", "vpn", "bluetooth", "easyEffects", "powerProfile", "idleInhibitor", "nightLight", "darkMode", "antiFlashbang", "cloudflareWarp", "mic", "musicRecognition", "notifications", "onScreenKeyboard", "gameMode", "screenSnip", "colorPicker" ]
|
||||||
}
|
}
|
||||||
property JsonObject calendar: JsonObject {
|
property JsonObject calendar: JsonObject {
|
||||||
property bool force2CharDayOfWeek: true
|
property bool force2CharDayOfWeek: true
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue