nosignal-shell/modules/controlcenter/network/VpnList.qml
2026-04-12 03:46:37 +10:00

840 lines
34 KiB
QML

pragma ComponentBehavior: Bound
import ".."
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Caelestia.Config
import qs.components
import qs.components.controls
import qs.components.effects
import qs.services
ColumnLayout {
id: root
required property Session session
property bool showHeader: true
property int pendingSwitchIndex: -1
spacing: Tokens.spacing.normal
Connections {
function onConnectedChanged() {
if (!VPN.connected && root.pendingSwitchIndex >= 0) {
const targetIndex = root.pendingSwitchIndex;
root.pendingSwitchIndex = -1;
const providers = [];
for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
const p = Config.utilities.vpn.provider[i];
if (typeof p === "object") {
const newProvider = {
name: p.name,
displayName: p.displayName,
interface: p.interface,
enabled: (i === targetIndex)
};
if (p.connectCmd && p.connectCmd.length > 0) {
newProvider.connectCmd = p.connectCmd;
}
if (p.disconnectCmd && p.disconnectCmd.length > 0) {
newProvider.disconnectCmd = p.disconnectCmd;
}
providers.push(newProvider);
} else {
providers.push(p);
}
}
GlobalConfig.utilities.vpn.provider = providers;
Qt.callLater(function () {
VPN.toggle();
});
}
}
target: VPN
}
TextButton {
Layout.fillWidth: true
text: qsTr("+ Add VPN Provider")
inactiveColour: Colours.palette.m3primaryContainer
inactiveOnColour: Colours.palette.m3onPrimaryContainer
onClicked: {
vpnDialog.showProviderSelection();
}
}
ListView {
id: listView
Layout.fillWidth: true
Layout.preferredHeight: contentHeight
interactive: false
spacing: Tokens.spacing.smaller
model: ScriptModel {
values: Config.utilities.vpn.provider.map((provider, index) => {
const isObject = typeof provider === "object";
const name = isObject ? (provider.name || "custom") : String(provider);
const displayName = isObject ? (provider.displayName || name) : name;
const iface = isObject ? (provider.interface || "") : "";
const enabled = isObject ? (provider.enabled === true) : false;
return {
index: index,
name: name,
displayName: displayName,
interface: iface,
provider: provider,
enabled: enabled
};
})
}
delegate: Component {
StyledRect {
required property var modelData
required property int index
width: ListView.view ? ListView.view.width : undefined
implicitHeight: rowLayout.implicitHeight + Tokens.padding.normal * 2
color: Qt.alpha(Colours.tPalette.m3surfaceContainer, (root.session && root.session.vpn && root.session.vpn.active === modelData) ? Colours.tPalette.m3surfaceContainer.a : 0)
radius: Tokens.rounding.normal
StateLayer {
function onClicked(): void {
if (root.session && root.session.vpn) {
root.session.vpn.active = modelData;
}
}
}
RowLayout {
id: rowLayout
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Tokens.padding.normal
spacing: Tokens.spacing.normal
StyledRect {
implicitWidth: implicitHeight
implicitHeight: icon.implicitHeight + Tokens.padding.normal * 2
radius: Tokens.rounding.normal
color: modelData.enabled && VPN.connected ? Colours.palette.m3primaryContainer : Colours.tPalette.m3surfaceContainerHigh
MaterialIcon {
id: icon
anchors.centerIn: parent
text: modelData.enabled && VPN.connected ? "vpn_key" : "vpn_key_off"
font.pointSize: Tokens.font.size.large
fill: modelData.enabled && VPN.connected ? 1 : 0
color: modelData.enabled && VPN.connected ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 0
StyledText {
Layout.fillWidth: true
elide: Text.ElideRight
maximumLineCount: 1
text: modelData.displayName || qsTr("Unknown")
}
RowLayout {
Layout.fillWidth: true
spacing: Tokens.spacing.smaller
StyledText {
Layout.fillWidth: true
text: {
if (!modelData.enabled)
return qsTr("Disabled");
if (VPN.connecting)
return qsTr("Connecting...");
switch (VPN.status.state) {
case "connected":
return qsTr("Connected");
case "disconnected":
return qsTr("Enabled");
case "connecting":
return qsTr("Connecting...");
case "needs-auth":
return qsTr("Auth required");
case "error":
return qsTr("Error");
default:
return qsTr("Enabled");
}
}
color: {
if (!modelData.enabled)
return Colours.palette.m3outline;
if (VPN.status.state === "connected")
return Colours.palette.m3primary;
if (VPN.status.state === "error")
return Colours.palette.m3error;
if (VPN.status.state === "needs-auth")
return Colours.palette.m3tertiary;
return Colours.palette.m3onSurface;
}
font.pointSize: Tokens.font.size.small
font.weight: modelData.enabled && VPN.connected ? 500 : 400
elide: Text.ElideRight
}
}
}
StyledRect {
implicitWidth: implicitHeight
implicitHeight: connectIcon.implicitHeight + Tokens.padding.smaller * 2
radius: Tokens.rounding.full
color: Qt.alpha(Colours.palette.m3primaryContainer, VPN.connected && modelData.enabled ? 1 : 0)
StateLayer {
function onClicked(): void {
const clickedIndex = modelData.index;
if (modelData.enabled) {
VPN.toggle();
} else {
if (VPN.connected) {
root.pendingSwitchIndex = clickedIndex;
VPN.toggle();
} else {
const providers = [];
for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
const p = Config.utilities.vpn.provider[i];
if (typeof p === "object") {
const newProvider = {
name: p.name,
displayName: p.displayName,
interface: p.interface,
enabled: (i === clickedIndex)
};
if (p.connectCmd && p.connectCmd.length > 0) {
newProvider.connectCmd = p.connectCmd;
}
if (p.disconnectCmd && p.disconnectCmd.length > 0) {
newProvider.disconnectCmd = p.disconnectCmd;
}
providers.push(newProvider);
} else {
providers.push(p);
}
}
GlobalConfig.utilities.vpn.provider = providers;
Qt.callLater(function () {
VPN.toggle();
});
}
}
}
enabled: !VPN.connecting
}
MaterialIcon {
id: connectIcon
anchors.centerIn: parent
text: VPN.connected && modelData.enabled ? "link_off" : "link"
color: VPN.connected && modelData.enabled ? Colours.palette.m3onPrimaryContainer : Colours.palette.m3onSurface
}
}
StyledRect {
implicitWidth: implicitHeight
implicitHeight: deleteIcon.implicitHeight + Tokens.padding.smaller * 2
radius: Tokens.rounding.full
color: "transparent"
StateLayer {
function onClicked(): void {
const providers = [];
for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
if (i !== modelData.index) {
const p = Config.utilities.vpn.provider[i];
const reconstructed = {
name: p.name,
displayName: p.displayName,
interface: p.interface,
enabled: p.enabled
};
if (p.connectCmd && p.connectCmd.length > 0) {
reconstructed.connectCmd = p.connectCmd;
}
if (p.disconnectCmd && p.disconnectCmd.length > 0) {
reconstructed.disconnectCmd = p.disconnectCmd;
}
providers.push(reconstructed);
}
}
GlobalConfig.utilities.vpn.provider = providers;
}
}
MaterialIcon {
id: deleteIcon
anchors.centerIn: parent
text: "delete"
color: Colours.palette.m3onSurface
}
}
}
}
}
}
Popup {
id: vpnDialog
property string currentState: "selection"
property int editIndex: -1
property string providerName: ""
property string displayName: ""
property string interfaceName: ""
property string connectCmd: ""
property string disconnectCmd: ""
function showProviderSelection(): void {
currentState = "selection";
open();
}
function closeWithAnimation(): void {
close();
}
function showAddForm(providerType: string, defaultDisplayName: string): void {
editIndex = -1;
providerName = providerType;
displayName = defaultDisplayName;
interfaceName = "";
connectCmd = "";
disconnectCmd = "";
if (currentState === "selection") {
transitionToForm.start();
} else {
currentState = "form";
isClosing = false;
open();
}
}
function showEditForm(index: int): void {
const provider = Config.utilities.vpn.provider[index];
const isObject = typeof provider === "object";
editIndex = index;
providerName = isObject ? (provider.name || "custom") : String(provider);
displayName = isObject ? (provider.displayName || providerName) : providerName;
interfaceName = isObject ? (provider.interface || "") : "";
connectCmd = isObject && provider.connectCmd ? provider.connectCmd.join(" ") : "";
disconnectCmd = isObject && provider.disconnectCmd ? provider.disconnectCmd.join(" ") : "";
currentState = "form";
open();
}
parent: Overlay.overlay
x: Math.round((parent.width - width) / 2)
y: Math.round((parent.height - height) / 2)
implicitWidth: Math.min(400, parent.width - Tokens.padding.large * 2)
padding: Tokens.padding.large * 1.5
modal: true
closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
opacity: 0
scale: 0.7
enter: Transition {
ParallelAnimation {
Anim {
property: "opacity"
from: 0
to: 1
duration: Tokens.anim.durations.normal
easing: Tokens.anim.emphasized
}
Anim {
property: "scale"
from: 0.7
to: 1
duration: Tokens.anim.durations.normal
easing: Tokens.anim.emphasized
}
}
}
exit: Transition {
ParallelAnimation {
Anim {
property: "opacity"
from: 1
to: 0
duration: Tokens.anim.durations.small
easing: Tokens.anim.emphasized
}
Anim {
property: "scale"
from: 1
to: 0.7
duration: Tokens.anim.durations.small
easing: Tokens.anim.emphasized
}
}
}
Overlay.modal: Rectangle {
color: Qt.rgba(0, 0, 0, 0.4 * vpnDialog.opacity)
}
onClosed: {
currentState = "selection";
}
background: StyledRect {
color: Colours.palette.m3surfaceContainerHigh
radius: Tokens.rounding.large
Elevation {
anchors.fill: parent
radius: parent.radius
level: 3
z: -1
}
Behavior on implicitHeight {
Anim {
duration: Tokens.anim.durations.normal
easing: Tokens.anim.emphasized
}
}
}
contentItem: Item {
implicitHeight: vpnDialog.currentState === "selection" ? selectionContent.implicitHeight : formContent.implicitHeight
Behavior on implicitHeight {
Anim {
duration: Tokens.anim.durations.normal
easing: Tokens.anim.emphasized
}
}
ColumnLayout {
id: selectionContent
anchors.fill: parent
spacing: Tokens.spacing.normal
visible: vpnDialog.currentState === "selection"
opacity: vpnDialog.currentState === "selection" ? 1 : 0
Behavior on opacity {
Anim {
duration: Tokens.anim.durations.small
easing: Tokens.anim.emphasized
}
}
StyledText {
text: qsTr("Add VPN Provider")
font.pointSize: Tokens.font.size.large
font.weight: 500
}
StyledText {
Layout.fillWidth: true
text: qsTr("Choose a provider to add")
wrapMode: Text.WordWrap
color: Colours.palette.m3outline
font.pointSize: Tokens.font.size.small
}
TextButton {
Layout.topMargin: Tokens.spacing.normal
Layout.fillWidth: true
text: qsTr("NetBird")
inactiveColour: Colours.tPalette.m3surfaceContainerHigh
inactiveOnColour: Colours.palette.m3onSurface
onClicked: {
const providers = [];
for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
providers.push(Config.utilities.vpn.provider[i]);
}
providers.push({
name: "netbird",
displayName: "NetBird",
interface: "wt0"
});
GlobalConfig.utilities.vpn.provider = providers;
vpnDialog.closeWithAnimation();
}
}
TextButton {
Layout.fillWidth: true
text: qsTr("Tailscale")
inactiveColour: Colours.tPalette.m3surfaceContainerHigh
inactiveOnColour: Colours.palette.m3onSurface
onClicked: {
const providers = [];
for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
providers.push(Config.utilities.vpn.provider[i]);
}
providers.push({
name: "tailscale",
displayName: "Tailscale",
interface: "tailscale0"
});
GlobalConfig.utilities.vpn.provider = providers;
vpnDialog.closeWithAnimation();
}
}
TextButton {
Layout.fillWidth: true
text: qsTr("Cloudflare WARP")
inactiveColour: Colours.tPalette.m3surfaceContainerHigh
inactiveOnColour: Colours.palette.m3onSurface
onClicked: {
const providers = [];
for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
providers.push(Config.utilities.vpn.provider[i]);
}
providers.push({
name: "warp",
displayName: "Cloudflare WARP",
interface: "CloudflareWARP"
});
GlobalConfig.utilities.vpn.provider = providers;
vpnDialog.closeWithAnimation();
}
}
TextButton {
Layout.fillWidth: true
text: qsTr("WireGuard")
inactiveColour: Colours.tPalette.m3surfaceContainerHigh
inactiveOnColour: Colours.palette.m3onSurface
onClicked: {
vpnDialog.showAddForm("wireguard", "WireGuard");
}
}
TextButton {
Layout.fillWidth: true
text: qsTr("Custom")
inactiveColour: Colours.tPalette.m3surfaceContainerHigh
inactiveOnColour: Colours.palette.m3onSurface
onClicked: {
vpnDialog.showAddForm("custom", "Custom VPN");
}
}
TextButton {
Layout.topMargin: Tokens.spacing.normal
Layout.fillWidth: true
text: qsTr("Cancel")
inactiveColour: Colours.palette.m3secondaryContainer
inactiveOnColour: Colours.palette.m3onSecondaryContainer
onClicked: vpnDialog.closeWithAnimation()
}
}
ColumnLayout {
id: formContent
anchors.fill: parent
spacing: Tokens.spacing.normal
visible: vpnDialog.currentState === "form"
opacity: vpnDialog.currentState === "form" ? 1 : 0
Behavior on opacity {
Anim {
duration: Tokens.anim.durations.small
easing: Tokens.anim.emphasized
}
}
StyledText {
text: vpnDialog.editIndex >= 0 ? qsTr("Edit VPN Provider") : qsTr("Add %1 VPN").arg(vpnDialog.displayName)
font.pointSize: Tokens.font.size.large
font.weight: 500
}
ColumnLayout {
Layout.fillWidth: true
spacing: Tokens.spacing.smaller / 2
StyledText {
text: qsTr("Display Name")
font.pointSize: Tokens.font.size.small
color: Colours.palette.m3onSurfaceVariant
}
StyledRect {
Layout.fillWidth: true
implicitHeight: 40
color: displayNameField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
radius: Tokens.rounding.small
border.width: 1
border.color: displayNameField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
Behavior on color {
CAnim {}
}
Behavior on border.color {
CAnim {}
}
StyledTextField {
id: displayNameField
anchors.centerIn: parent
width: parent.width - Tokens.padding.normal
horizontalAlignment: TextInput.AlignLeft
text: vpnDialog.displayName
onTextChanged: vpnDialog.displayName = text
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: Tokens.spacing.smaller / 2
StyledText {
text: qsTr("Interface (e.g., wg0, torguard)")
font.pointSize: Tokens.font.size.small
color: Colours.palette.m3onSurfaceVariant
}
StyledRect {
Layout.fillWidth: true
implicitHeight: 40
color: interfaceNameField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
radius: Tokens.rounding.small
border.width: 1
border.color: interfaceNameField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
Behavior on color {
CAnim {}
}
Behavior on border.color {
CAnim {}
}
StyledTextField {
id: interfaceNameField
anchors.centerIn: parent
width: parent.width - Tokens.padding.normal
horizontalAlignment: TextInput.AlignLeft
text: vpnDialog.interfaceName
onTextChanged: vpnDialog.interfaceName = text
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: Tokens.spacing.smaller / 2
visible: vpnDialog.editIndex >= 0 ? (vpnDialog.connectCmd.length > 0) : (vpnDialog.providerName === "custom")
StyledText {
text: qsTr("Connect Command (e.g., wg-quick up wg0)")
font.pointSize: Tokens.font.size.small
color: Colours.palette.m3onSurfaceVariant
}
StyledRect {
Layout.fillWidth: true
implicitHeight: 40
color: connectCmdField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
radius: Tokens.rounding.small
border.width: 1
border.color: connectCmdField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
Behavior on color {
CAnim {}
}
Behavior on border.color {
CAnim {}
}
StyledTextField {
id: connectCmdField
anchors.centerIn: parent
width: parent.width - Tokens.padding.normal
horizontalAlignment: TextInput.AlignLeft
text: vpnDialog.connectCmd
onTextChanged: vpnDialog.connectCmd = text
}
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: Tokens.spacing.smaller / 2
visible: vpnDialog.editIndex >= 0 ? (vpnDialog.connectCmd.length > 0) : (vpnDialog.providerName === "custom")
StyledText {
text: qsTr("Disconnect Command (e.g., wg-quick down wg0)")
font.pointSize: Tokens.font.size.small
color: Colours.palette.m3onSurfaceVariant
}
StyledRect {
Layout.fillWidth: true
implicitHeight: 40
color: disconnectCmdField.activeFocus ? Colours.layer(Colours.palette.m3surfaceContainer, 3) : Colours.layer(Colours.palette.m3surfaceContainer, 2)
radius: Tokens.rounding.small
border.width: 1
border.color: disconnectCmdField.activeFocus ? Colours.palette.m3primary : Qt.alpha(Colours.palette.m3outline, 0.3)
Behavior on color {
CAnim {}
}
Behavior on border.color {
CAnim {}
}
StyledTextField {
id: disconnectCmdField
anchors.centerIn: parent
width: parent.width - Tokens.padding.normal
horizontalAlignment: TextInput.AlignLeft
text: vpnDialog.disconnectCmd
onTextChanged: vpnDialog.disconnectCmd = text
}
}
}
RowLayout {
Layout.topMargin: Tokens.spacing.normal
Layout.fillWidth: true
spacing: Tokens.spacing.normal
TextButton {
Layout.fillWidth: true
text: qsTr("Cancel")
inactiveColour: Colours.tPalette.m3surfaceContainerHigh
inactiveOnColour: Colours.palette.m3onSurface
onClicked: vpnDialog.closeWithAnimation()
}
TextButton {
Layout.fillWidth: true
text: qsTr("Save")
enabled: {
const hasCommands = vpnDialog.connectCmd.length > 0 || vpnDialog.disconnectCmd.length > 0;
if (hasCommands) {
return vpnDialog.interfaceName.length > 0 && vpnDialog.connectCmd.length > 0 && vpnDialog.disconnectCmd.length > 0;
}
return vpnDialog.interfaceName.length > 0;
}
inactiveColour: Colours.palette.m3primaryContainer
inactiveOnColour: Colours.palette.m3onPrimaryContainer
onClicked: {
const providers = [];
const hasCommands = vpnDialog.connectCmd.length > 0 && vpnDialog.disconnectCmd.length > 0;
const newProvider = {
displayName: vpnDialog.displayName || vpnDialog.interfaceName,
enabled: false,
interface: vpnDialog.interfaceName,
name: vpnDialog.providerName
};
if (hasCommands) {
newProvider.connectCmd = vpnDialog.connectCmd.split(" ").filter(s => s.length > 0);
newProvider.disconnectCmd = vpnDialog.disconnectCmd.split(" ").filter(s => s.length > 0);
}
if (vpnDialog.editIndex >= 0) {
const oldProvider = Config.utilities.vpn.provider[vpnDialog.editIndex];
if (typeof oldProvider === "object" && oldProvider.enabled !== undefined) {
newProvider.enabled = oldProvider.enabled;
}
for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
if (i === vpnDialog.editIndex) {
providers.push(newProvider);
} else {
providers.push(Config.utilities.vpn.provider[i]);
}
}
} else {
for (let i = 0; i < Config.utilities.vpn.provider.length; i++) {
providers.push(Config.utilities.vpn.provider[i]);
}
providers.push(newProvider);
}
GlobalConfig.utilities.vpn.provider = providers;
vpnDialog.closeWithAnimation();
}
}
}
}
}
SequentialAnimation {
id: transitionToForm
ParallelAnimation {
Anim {
target: selectionContent
property: "opacity"
to: 0
duration: Tokens.anim.durations.small
easing: Tokens.anim.emphasized
}
}
ScriptAction {
script: {
vpnDialog.currentState = "form";
}
}
ParallelAnimation {
Anim {
target: formContent
property: "opacity"
to: 1
duration: Tokens.anim.durations.small
easing: Tokens.anim.emphasized
}
}
}
}
}