controlcenter: wifi passphrase input
fix: dependability issues with nmcli
This commit is contained in:
parent
81a8157b67
commit
d8de7c7e4b
5 changed files with 451 additions and 13 deletions
|
|
@ -79,17 +79,37 @@ Item {
|
||||||
checked: root.network?.active ?? false
|
checked: root.network?.active ?? false
|
||||||
toggle.onToggled: {
|
toggle.onToggled: {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
if (root.network.isSecure) {
|
// If already connected to a different network, disconnect first
|
||||||
// TODO: Show password dialog
|
if (Network.active && Network.active.ssid !== root.network.ssid) {
|
||||||
root.session.network.showPasswordDialog = true;
|
Network.disconnectFromNetwork();
|
||||||
root.session.network.pendingNetwork = root.network;
|
// Wait a moment before connecting to new network
|
||||||
|
Qt.callLater(() => {
|
||||||
|
connectToNetwork();
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
Network.connectToNetwork(root.network.ssid, "");
|
connectToNetwork();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Network.disconnectFromNetwork();
|
Network.disconnectFromNetwork();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function connectToNetwork(): void {
|
||||||
|
if (root.network.isSecure) {
|
||||||
|
// Try connecting without password first (in case it's saved)
|
||||||
|
Network.connectToNetworkWithPasswordCheck(
|
||||||
|
root.network.ssid,
|
||||||
|
root.network.isSecure,
|
||||||
|
() => {
|
||||||
|
// Callback: connection failed, show password dialog
|
||||||
|
root.session.network.showPasswordDialog = true;
|
||||||
|
root.session.network.pendingNetwork = root.network;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Network.connectToNetwork(root.network.ssid, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -214,14 +214,35 @@ ColumnLayout {
|
||||||
if (modelData.active) {
|
if (modelData.active) {
|
||||||
Network.disconnectFromNetwork();
|
Network.disconnectFromNetwork();
|
||||||
} else {
|
} else {
|
||||||
if (modelData.isSecure) {
|
// If already connected to a different network, disconnect first
|
||||||
root.session.network.showPasswordDialog = true;
|
if (Network.active && Network.active.ssid !== modelData.ssid) {
|
||||||
root.session.network.pendingNetwork = modelData;
|
Network.disconnectFromNetwork();
|
||||||
|
// Wait a moment before connecting to new network
|
||||||
|
Qt.callLater(() => {
|
||||||
|
connectToNetwork();
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
Network.connectToNetwork(modelData.ssid, "");
|
connectToNetwork();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function connectToNetwork(): void {
|
||||||
|
if (modelData.isSecure) {
|
||||||
|
// Try connecting without password first (in case it's saved)
|
||||||
|
Network.connectToNetworkWithPasswordCheck(
|
||||||
|
modelData.ssid,
|
||||||
|
modelData.isSecure,
|
||||||
|
() => {
|
||||||
|
// Callback: connection failed, show password dialog
|
||||||
|
root.session.network.showPasswordDialog = true;
|
||||||
|
root.session.network.pendingNetwork = modelData;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Network.connectToNetwork(modelData.ssid, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MaterialIcon {
|
MaterialIcon {
|
||||||
|
|
|
||||||
|
|
@ -76,4 +76,10 @@ RowLayout {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PasswordDialog {
|
||||||
|
anchors.fill: parent
|
||||||
|
session: root.session
|
||||||
|
z: 1000
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
241
modules/controlcenter/network/PasswordDialog.qml
Normal file
241
modules/controlcenter/network/PasswordDialog.qml
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
pragma ComponentBehavior: Bound
|
||||||
|
|
||||||
|
import ".."
|
||||||
|
import qs.components
|
||||||
|
import qs.components.controls
|
||||||
|
import qs.components.effects
|
||||||
|
import qs.components.containers
|
||||||
|
import qs.services
|
||||||
|
import qs.config
|
||||||
|
import QtQuick
|
||||||
|
import QtQuick.Layouts
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
required property Session session
|
||||||
|
readonly property var network: session.network.pendingNetwork
|
||||||
|
|
||||||
|
visible: session.network.showPasswordDialog
|
||||||
|
enabled: visible
|
||||||
|
focus: visible
|
||||||
|
|
||||||
|
Keys.onEscapePressed: {
|
||||||
|
root.session.network.showPasswordDialog = false;
|
||||||
|
passwordField.text = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
color: Qt.rgba(0, 0, 0, 0.5)
|
||||||
|
opacity: root.visible ? 1 : 0
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation {
|
||||||
|
duration: 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
onClicked: {
|
||||||
|
root.session.network.showPasswordDialog = false;
|
||||||
|
passwordField.text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledRect {
|
||||||
|
id: dialog
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
|
||||||
|
implicitWidth: 400
|
||||||
|
implicitHeight: content.implicitHeight + Appearance.padding.large * 2
|
||||||
|
|
||||||
|
radius: Appearance.rounding.normal
|
||||||
|
color: Colours.tPalette.m3surface
|
||||||
|
opacity: root.visible ? 1 : 0
|
||||||
|
scale: root.visible ? 1 : 0.9
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation {
|
||||||
|
duration: 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Behavior on scale {
|
||||||
|
NumberAnimation {
|
||||||
|
duration: 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys.onEscapePressed: {
|
||||||
|
root.session.network.showPasswordDialog = false;
|
||||||
|
passwordField.text = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: content
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: Appearance.padding.large
|
||||||
|
|
||||||
|
spacing: Appearance.spacing.normal
|
||||||
|
|
||||||
|
MaterialIcon {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: "lock"
|
||||||
|
font.pointSize: Appearance.font.size.extraLarge * 2
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: qsTr("Enter password")
|
||||||
|
font.pointSize: Appearance.font.size.large
|
||||||
|
font.weight: 500
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
Layout.alignment: Qt.AlignHCenter
|
||||||
|
text: root.network ? qsTr("Network: %1").arg(root.network.ssid) : ""
|
||||||
|
color: Colours.palette.m3outline
|
||||||
|
font.pointSize: Appearance.font.size.small
|
||||||
|
}
|
||||||
|
|
||||||
|
Item {
|
||||||
|
Layout.topMargin: Appearance.spacing.large
|
||||||
|
Layout.fillWidth: true
|
||||||
|
implicitHeight: passwordField.implicitHeight + Appearance.padding.normal * 2
|
||||||
|
|
||||||
|
StyledRect {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: Appearance.rounding.normal
|
||||||
|
color: Colours.tPalette.m3surfaceContainer
|
||||||
|
border.width: passwordField.activeFocus ? 2 : 1
|
||||||
|
border.color: passwordField.activeFocus ? Colours.palette.m3primary : Colours.palette.m3outline
|
||||||
|
|
||||||
|
Behavior on border.color {
|
||||||
|
CAnim {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledTextField {
|
||||||
|
id: passwordField
|
||||||
|
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.verticalCenter: parent.verticalCenter
|
||||||
|
anchors.margins: Appearance.padding.normal
|
||||||
|
|
||||||
|
echoMode: TextField.Password
|
||||||
|
placeholderText: qsTr("Password")
|
||||||
|
|
||||||
|
Component.onCompleted: {
|
||||||
|
if (root.visible) {
|
||||||
|
forceActiveFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: root
|
||||||
|
function onVisibleChanged(): void {
|
||||||
|
if (root.visible) {
|
||||||
|
passwordField.forceActiveFocus();
|
||||||
|
passwordField.text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Keys.onReturnPressed: {
|
||||||
|
if (connectButton.enabled) {
|
||||||
|
connectButton.clicked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Keys.onEnterPressed: {
|
||||||
|
if (connectButton.enabled) {
|
||||||
|
connectButton.clicked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.topMargin: Appearance.spacing.normal
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: Appearance.spacing.normal
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: cancelButton
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
color: Colours.palette.m3secondaryContainer
|
||||||
|
onColor: Colours.palette.m3onSecondaryContainer
|
||||||
|
text: qsTr("Cancel")
|
||||||
|
|
||||||
|
function onClicked(): void {
|
||||||
|
root.session.network.showPasswordDialog = false;
|
||||||
|
passwordField.text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: connectButton
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
color: Colours.palette.m3primary
|
||||||
|
onColor: Colours.palette.m3onPrimary
|
||||||
|
text: qsTr("Connect")
|
||||||
|
enabled: passwordField.text.length > 0
|
||||||
|
|
||||||
|
function onClicked(): void {
|
||||||
|
if (root.network && passwordField.text.length > 0) {
|
||||||
|
Network.connectToNetwork(root.network.ssid, passwordField.text);
|
||||||
|
root.session.network.showPasswordDialog = false;
|
||||||
|
passwordField.text = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
component Button: StyledRect {
|
||||||
|
property color onColor: Colours.palette.m3onSurface
|
||||||
|
property alias disabled: stateLayer.disabled
|
||||||
|
property alias text: label.text
|
||||||
|
property alias enabled: stateLayer.enabled
|
||||||
|
|
||||||
|
function onClicked(): void {
|
||||||
|
}
|
||||||
|
|
||||||
|
radius: Appearance.rounding.small
|
||||||
|
implicitHeight: label.implicitHeight + Appearance.padding.small * 2
|
||||||
|
opacity: enabled ? 1 : 0.5
|
||||||
|
|
||||||
|
StateLayer {
|
||||||
|
id: stateLayer
|
||||||
|
|
||||||
|
enabled: parent.enabled
|
||||||
|
color: parent.onColor
|
||||||
|
|
||||||
|
function onClicked(): void {
|
||||||
|
if (enabled) {
|
||||||
|
parent.onClicked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
StyledText {
|
||||||
|
id: label
|
||||||
|
|
||||||
|
anchors.centerIn: parent
|
||||||
|
animate: true
|
||||||
|
color: parent.onColor
|
||||||
|
font.pointSize: Appearance.font.size.normal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -26,20 +26,42 @@ Singleton {
|
||||||
rescanProc.running = true;
|
rescanProc.running = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
property var pendingConnection: null
|
||||||
|
signal connectionFailed(string ssid)
|
||||||
|
|
||||||
function connectToNetwork(ssid: string, password: string): void {
|
function connectToNetwork(ssid: string, password: string): void {
|
||||||
// First try to connect to an existing connection
|
// First try to connect to an existing connection
|
||||||
// If that fails, create a new connection
|
// If that fails, create a new connection
|
||||||
if (password && password.length > 0) {
|
if (password && password.length > 0) {
|
||||||
connectProc.exec(["nmcli", "device", "wifi", "connect", ssid, "password", password]);
|
connectProc.exec(["nmcli", "device", "wifi", "connect", ssid, "password", password]);
|
||||||
} else {
|
} else {
|
||||||
// Try to connect to existing connection first
|
// Try to connect to existing connection first (will use saved password if available)
|
||||||
connectProc.exec(["nmcli", "device", "wifi", "connect", ssid]);
|
connectProc.exec(["nmcli", "device", "wifi", "connect", ssid]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function connectToNetworkWithPasswordCheck(ssid: string, isSecure: bool, callback: var): void {
|
||||||
|
// For secure networks, try connecting without password first
|
||||||
|
// If connection succeeds (saved password exists), we're done
|
||||||
|
// If it fails with password error, callback will be called to show password dialog
|
||||||
|
if (isSecure) {
|
||||||
|
root.pendingConnection = { ssid: ssid, callback: callback };
|
||||||
|
// Try connecting without password - will use saved password if available
|
||||||
|
connectProc.exec(["nmcli", "device", "wifi", "connect", ssid]);
|
||||||
|
// Start timer to check if connection succeeded
|
||||||
|
connectionCheckTimer.start();
|
||||||
|
} else {
|
||||||
|
connectToNetwork(ssid, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function disconnectFromNetwork(): void {
|
function disconnectFromNetwork(): void {
|
||||||
if (active) {
|
// Try to disconnect - use connection name if available, otherwise use device
|
||||||
// Find the device name first, then disconnect
|
if (active && active.ssid) {
|
||||||
|
// First try to disconnect by connection name (more reliable)
|
||||||
|
disconnectByConnectionProc.exec(["nmcli", "connection", "down", active.ssid]);
|
||||||
|
} else {
|
||||||
|
// Fallback: disconnect by device
|
||||||
disconnectProc.exec(["nmcli", "device", "disconnect", "wifi"]);
|
disconnectProc.exec(["nmcli", "device", "disconnect", "wifi"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -90,18 +112,102 @@ Singleton {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: connectionCheckTimer
|
||||||
|
interval: 4000
|
||||||
|
onTriggered: {
|
||||||
|
if (root.pendingConnection) {
|
||||||
|
// Final check - if connection still hasn't succeeded, show password dialog
|
||||||
|
const connected = root.active && root.active.ssid === root.pendingConnection.ssid;
|
||||||
|
if (!connected && root.pendingConnection.callback) {
|
||||||
|
// Connection didn't succeed after multiple checks, show password dialog
|
||||||
|
const pending = root.pendingConnection;
|
||||||
|
root.pendingConnection = null;
|
||||||
|
immediateCheckTimer.stop();
|
||||||
|
immediateCheckTimer.checkCount = 0;
|
||||||
|
pending.callback();
|
||||||
|
} else if (connected) {
|
||||||
|
// Connection succeeded, clear pending
|
||||||
|
root.pendingConnection = null;
|
||||||
|
immediateCheckTimer.stop();
|
||||||
|
immediateCheckTimer.checkCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: immediateCheckTimer
|
||||||
|
interval: 500
|
||||||
|
repeat: true
|
||||||
|
triggeredOnStart: false
|
||||||
|
property int checkCount: 0
|
||||||
|
onTriggered: {
|
||||||
|
if (root.pendingConnection) {
|
||||||
|
checkCount++;
|
||||||
|
const connected = root.active && root.active.ssid === root.pendingConnection.ssid;
|
||||||
|
if (connected) {
|
||||||
|
// Connection succeeded, stop timers and clear pending
|
||||||
|
connectionCheckTimer.stop();
|
||||||
|
immediateCheckTimer.stop();
|
||||||
|
immediateCheckTimer.checkCount = 0;
|
||||||
|
root.pendingConnection = null;
|
||||||
|
} else if (checkCount >= 6) {
|
||||||
|
// Checked 6 times (3 seconds total), connection likely failed
|
||||||
|
// Stop immediate check, let the main timer handle it
|
||||||
|
immediateCheckTimer.stop();
|
||||||
|
immediateCheckTimer.checkCount = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
immediateCheckTimer.stop();
|
||||||
|
immediateCheckTimer.checkCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
id: connectProc
|
id: connectProc
|
||||||
|
|
||||||
onExited: {
|
onExited: {
|
||||||
// Refresh network list after connection attempt
|
// Refresh network list after connection attempt
|
||||||
getNetworks.running = true;
|
getNetworks.running = true;
|
||||||
|
|
||||||
|
// Check if connection succeeded after a short delay (network list needs to update)
|
||||||
|
if (root.pendingConnection) {
|
||||||
|
immediateCheckTimer.start();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
stdout: SplitParser {
|
stdout: SplitParser {
|
||||||
onRead: getNetworks.running = true
|
onRead: getNetworks.running = true
|
||||||
}
|
}
|
||||||
stderr: StdioCollector {
|
stderr: StdioCollector {
|
||||||
onStreamFinished: console.warn("Network connection error:", text)
|
onStreamFinished: {
|
||||||
|
const error = text.trim();
|
||||||
|
if (error && error.length > 0) {
|
||||||
|
// Check for specific errors that indicate password is needed
|
||||||
|
// Be careful not to match success messages
|
||||||
|
const needsPassword = (error.includes("Secrets were required") ||
|
||||||
|
error.includes("No secrets provided") ||
|
||||||
|
error.includes("802-11-wireless-security.psk") ||
|
||||||
|
(error.includes("password") && !error.includes("Connection activated")) ||
|
||||||
|
(error.includes("Secrets") && !error.includes("Connection activated")) ||
|
||||||
|
(error.includes("802.11") && !error.includes("Connection activated"))) &&
|
||||||
|
!error.includes("Connection activated") &&
|
||||||
|
!error.includes("successfully");
|
||||||
|
|
||||||
|
if (needsPassword && root.pendingConnection && root.pendingConnection.callback) {
|
||||||
|
// Connection failed because password is needed - show dialog immediately
|
||||||
|
connectionCheckTimer.stop();
|
||||||
|
immediateCheckTimer.stop();
|
||||||
|
const pending = root.pendingConnection;
|
||||||
|
root.pendingConnection = null;
|
||||||
|
pending.callback();
|
||||||
|
} else if (error && error.length > 0 && !error.includes("Connection activated")) {
|
||||||
|
// Only log non-success messages
|
||||||
|
console.warn("Network connection error:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -115,6 +221,36 @@ Singleton {
|
||||||
stdout: SplitParser {
|
stdout: SplitParser {
|
||||||
onRead: getNetworks.running = true
|
onRead: getNetworks.running = true
|
||||||
}
|
}
|
||||||
|
stderr: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
const error = text.trim();
|
||||||
|
if (error && error.length > 0 && !error.includes("successfully") && !error.includes("disconnected")) {
|
||||||
|
console.warn("Network device disconnect error:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Process {
|
||||||
|
id: disconnectByConnectionProc
|
||||||
|
|
||||||
|
onExited: {
|
||||||
|
// Refresh network list after disconnection
|
||||||
|
getNetworks.running = true;
|
||||||
|
}
|
||||||
|
stdout: SplitParser {
|
||||||
|
onRead: getNetworks.running = true
|
||||||
|
}
|
||||||
|
stderr: StdioCollector {
|
||||||
|
onStreamFinished: {
|
||||||
|
const error = text.trim();
|
||||||
|
if (error && error.length > 0 && !error.includes("successfully") && !error.includes("disconnected")) {
|
||||||
|
console.warn("Network connection disconnect error:", error);
|
||||||
|
// If connection down failed, try device disconnect as fallback
|
||||||
|
disconnectProc.exec(["nmcli", "device", "disconnect", "wifi"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Process {
|
Process {
|
||||||
|
|
@ -182,6 +318,20 @@ Singleton {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if pending connection succeeded after network list is fully updated
|
||||||
|
if (root.pendingConnection) {
|
||||||
|
Qt.callLater(() => {
|
||||||
|
const connected = root.active && root.active.ssid === root.pendingConnection.ssid;
|
||||||
|
if (connected) {
|
||||||
|
// Connection succeeded, stop timers and clear pending
|
||||||
|
connectionCheckTimer.stop();
|
||||||
|
immediateCheckTimer.stop();
|
||||||
|
immediateCheckTimer.checkCount = 0;
|
||||||
|
root.pendingConnection = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue