356 lines
15 KiB
QML
356 lines
15 KiB
QML
// The facts about one connection that otherwise need a terminal, and the two
|
|
// settings that change them.
|
|
//
|
|
// IP address, gateway, DNS and MAC are the four things people leave this app
|
|
// for, and they were the reason the Connections page still pointed at GNOME.
|
|
// The facts above are what is on the wire; the editors below are what the
|
|
// PROFILE asks for, which is a different question -- a static address that has
|
|
// not been applied yet is in the second and not the first, and a component that
|
|
// showed only the first would look like it had forgotten what was typed.
|
|
//
|
|
// Nothing here is ever a secret. panama-network's `details` verb returns
|
|
// addresses only -- no PSK, no enterprise password -- so this component can be
|
|
// shown for any connection without deciding what is safe to draw.
|
|
//
|
|
// Nothing applies until Apply. A half-typed address is a draft, not a broken
|
|
// network: committing per keystroke would take the connection down somewhere
|
|
// around the second octet. Same proxy-draft shape ConnectivityPage uses -- each
|
|
// field starts as a binding to the profile and stops being one at the first
|
|
// edit, so a reply landing mid-edit cannot empty the box being typed into.
|
|
//
|
|
// The values are set in the interface face with tabular figures rather than a
|
|
// monospaced one. Theme bans monospaced text outright (fontMono is the icon
|
|
// face, not a text face), and an address only needs its digits to line up.
|
|
|
|
import QtQuick
|
|
import qs.config
|
|
import qs.services
|
|
|
|
Column {
|
|
id: root
|
|
|
|
// { ip4, gateway, dns: [], mac, macRandomized, metered, ip4Method … } as
|
|
// the helper reports it, or null while the read has not come back. Null is
|
|
// NOT "no address": the component says it is still reading rather than
|
|
// claiming an answer.
|
|
property var details: null
|
|
|
|
// The profile these facts belong to. Empty means "facts only" -- there is
|
|
// nothing to write to, so the editors do not appear. Every caller that has
|
|
// a connection name should pass it.
|
|
property string connection: ""
|
|
|
|
// Editing needs both a name to write to and an answer to edit from.
|
|
readonly property bool editable: root.connection !== "" && !!root.details
|
|
|
|
// [{ key, value, note }] -- only the facts that actually have a value, so a
|
|
// connection with no gateway shows three rows rather than a blank one.
|
|
readonly property var facts: {
|
|
const source = root.details;
|
|
if (!source)
|
|
return [];
|
|
|
|
const rows = [];
|
|
const ip4 = String(source.ip4 ?? "");
|
|
if (ip4 !== "")
|
|
rows.push({ key: "IPv4 address", value: ip4, note: "" });
|
|
|
|
const ip6 = String(source.ip6 ?? "");
|
|
if (ip6 !== "")
|
|
rows.push({ key: "IPv6 address", value: ip6, note: "" });
|
|
|
|
const gateway = String(source.gateway ?? "");
|
|
if (gateway !== "")
|
|
rows.push({ key: "Gateway", value: gateway, note: "" });
|
|
|
|
const dns = Array.isArray(source.dns)
|
|
? source.dns.map(entry => String(entry)).filter(entry => entry !== "")
|
|
: [];
|
|
if (dns.length > 0)
|
|
rows.push({
|
|
key: dns.length === 1 ? "DNS" : "DNS servers",
|
|
value: dns.join(" · "),
|
|
note: ""
|
|
});
|
|
|
|
const mac = String(source.mac ?? "");
|
|
if (mac !== "")
|
|
rows.push({
|
|
key: "MAC address",
|
|
value: mac,
|
|
note: source.macRandomized === true ? "randomized" : ""
|
|
});
|
|
|
|
return rows;
|
|
}
|
|
|
|
width: parent ? parent.width : 620
|
|
spacing: 5
|
|
topPadding: 8
|
|
bottomPadding: 10
|
|
|
|
Repeater {
|
|
model: root.facts
|
|
|
|
Item {
|
|
id: fact
|
|
|
|
required property var modelData
|
|
|
|
width: parent.width
|
|
implicitHeight: Math.max(key.implicitHeight, value.implicitHeight)
|
|
|
|
Text {
|
|
id: key
|
|
anchors.left: parent.left
|
|
anchors.top: parent.top
|
|
width: 132
|
|
text: String(fact.modelData.key ?? "")
|
|
color: Theme.fgMuted
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
elide: Text.ElideRight
|
|
}
|
|
|
|
Text {
|
|
id: value
|
|
anchors.left: key.right
|
|
anchors.right: parent.right
|
|
anchors.top: parent.top
|
|
text: String(fact.modelData.value ?? "")
|
|
+ (String(fact.modelData.note ?? "") !== ""
|
|
? " · " + String(fact.modelData.note) : "")
|
|
color: Theme.fg
|
|
font.family: Theme.fontFamily
|
|
font.features: Theme.tabularFigures
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
wrapMode: Text.WrapAnywhere
|
|
}
|
|
}
|
|
}
|
|
|
|
// Two honest empty states, and they say different things. "Still reading"
|
|
// is not "no address", and a page that renders the first as the second is
|
|
// how a working connection comes to look broken for half a second.
|
|
Text {
|
|
width: parent.width
|
|
visible: !root.details
|
|
text: "Reading this connection's addresses…"
|
|
color: Theme.fgMuted
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
}
|
|
|
|
Text {
|
|
width: parent.width
|
|
visible: !!root.details && root.facts.length === 0
|
|
text: "NetworkManager reports no addresses for this connection."
|
|
color: Theme.fgMuted
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
// The helper's own aside -- chiefly "this takes effect on the next
|
|
// reconnect" after a MAC randomization change. It belongs to the answer,
|
|
// so it is shown with the answer rather than guessed at by the page.
|
|
Text {
|
|
width: parent.width
|
|
visible: String(root.details?.note ?? "") !== ""
|
|
topPadding: 4
|
|
text: String(root.details?.note ?? "")
|
|
color: Theme.fgMuted
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSizeSmall
|
|
wrapMode: Text.WordWrap
|
|
}
|
|
|
|
// ── Metered ─────────────────────────────────────────────────────────────
|
|
//
|
|
// Three states in NetworkManager, two in this switch, and the difference is
|
|
// said rather than hidden: "automatic" is NetworkManager deciding from what
|
|
// the network told it, which is a guess, and the detail line says so while
|
|
// it is the state in force.
|
|
SwitchRow {
|
|
width: parent.width
|
|
visible: root.editable
|
|
label: "Metered connection"
|
|
detail: String(root.details?.metered ?? "auto") === "auto"
|
|
? "NetworkManager is deciding for itself. Turn this on to hold updates and large downloads back until you are somewhere unmetered."
|
|
: "Updates and large downloads wait until you are somewhere unmetered"
|
|
checked: String(root.details?.metered ?? "auto") === "yes"
|
|
enabled: !NetworkTools.busy
|
|
onToggled: value => NetworkTools.setMetered(root.connection, value ? "yes" : "no")
|
|
}
|
|
|
|
// ── Addressing, one stack at a time ─────────────────────────────────────
|
|
//
|
|
// A Repeater over the two families rather than two hand-written copies: the
|
|
// drafts, the validation and the Apply are identical, and the only things
|
|
// that differ are the property prefix and what an address looks like.
|
|
//
|
|
// Each stack applies on its own. The helper writes a whole stack in one
|
|
// nmcli call and reactivates the connection afterwards, and NetworkTools
|
|
// runs one mutation at a time -- so a single Apply for both would silently
|
|
// drop one of them.
|
|
Repeater {
|
|
model: [
|
|
{
|
|
family: "4",
|
|
label: "IPv4",
|
|
addressHint: "192.168.1.50/24",
|
|
gatewayHint: "192.168.1.1",
|
|
dnsHint: "1.1.1.1, 9.9.9.9"
|
|
},
|
|
{
|
|
family: "6",
|
|
label: "IPv6",
|
|
addressHint: "fd00::42/64",
|
|
gatewayHint: "fd00::1",
|
|
dnsHint: "2606:4700:4700::1111"
|
|
}
|
|
]
|
|
|
|
Column {
|
|
id: stack
|
|
|
|
required property var modelData
|
|
|
|
readonly property bool six: String(stack.modelData.family) === "6"
|
|
|
|
// What the profile says now. Bound, so an Apply that succeeds
|
|
// reseeds every field nobody has touched.
|
|
readonly property string profileMethod:
|
|
String((stack.six ? root.details?.ip6Method : root.details?.ip4Method) ?? "")
|
|
=== "manual" ? "manual" : "auto"
|
|
readonly property string profileAddress: {
|
|
const list = (stack.six ? root.details?.ip6Addresses : root.details?.ip4Addresses) ?? [];
|
|
return list.length > 0 ? String(list[0]) : "";
|
|
}
|
|
readonly property string profileGateway:
|
|
String((stack.six ? root.details?.ip6Gateway : root.details?.ip4Gateway) ?? "")
|
|
readonly property string profileDns: {
|
|
const list = (stack.six ? root.details?.ip6Dns : root.details?.ip4Dns) ?? [];
|
|
return list.map(entry => String(entry)).join(", ");
|
|
}
|
|
|
|
// The drafts. Bindings until the first edit, and the user's after
|
|
// it -- see the header.
|
|
property string draftMethod: stack.profileMethod
|
|
property string draftAddress: stack.profileAddress
|
|
property string draftGateway: stack.profileGateway
|
|
property string draftDns: stack.profileDns
|
|
|
|
// The helper validates properly and is the authority. This is the
|
|
// same shape one step earlier, so Apply is dark rather than a round
|
|
// trip that comes back refused.
|
|
readonly property bool addressValid: stack.six
|
|
? (stack.draftAddress.indexOf(":") >= 0
|
|
&& /^[0-9A-Fa-f:]{2,45}\/(12[0-8]|1[01][0-9]|[1-9]?[0-9])$/.test(stack.draftAddress))
|
|
: /^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\/(3[0-2]|[12]?[0-9])$/.test(stack.draftAddress)
|
|
|
|
readonly property bool dirty: stack.draftMethod !== stack.profileMethod
|
|
|| (stack.draftMethod === "manual"
|
|
&& (stack.draftAddress !== stack.profileAddress
|
|
|| stack.draftGateway !== stack.profileGateway
|
|
|| stack.draftDns !== stack.profileDns))
|
|
|
|
function apply(): void {
|
|
if (root.connection === "")
|
|
return;
|
|
if (stack.draftMethod === "auto") {
|
|
NetworkTools.setIpAuto(root.connection, stack.modelData.family);
|
|
return;
|
|
}
|
|
if (!stack.addressValid)
|
|
return;
|
|
NetworkTools.setIpManual(root.connection, stack.modelData.family,
|
|
stack.draftAddress.trim(),
|
|
stack.draftGateway.trim(),
|
|
stack.draftDns.trim());
|
|
}
|
|
|
|
width: parent.width
|
|
visible: root.editable
|
|
|
|
OptionPickerRow {
|
|
width: parent.width
|
|
label: String(stack.modelData.label)
|
|
detail: stack.profileMethod === "manual"
|
|
? "This connection asks for an address you chose"
|
|
: "This connection takes whatever the network hands it"
|
|
enabled: !NetworkTools.busy
|
|
options: [
|
|
{
|
|
value: "auto",
|
|
label: "Automatic",
|
|
detail: stack.six
|
|
? "Router advertisements and DHCPv6, as the network offers them"
|
|
: "DHCP, as the network offers it"
|
|
},
|
|
{
|
|
value: "manual",
|
|
label: "Manual",
|
|
detail: "An address, gateway and nameservers you enter"
|
|
}
|
|
]
|
|
current: stack.draftMethod
|
|
onPicked: value => stack.draftMethod = String(value)
|
|
}
|
|
|
|
TextFieldRow {
|
|
width: parent.width
|
|
visible: stack.draftMethod === "manual"
|
|
label: "Address / prefix"
|
|
detail: stack.draftAddress !== "" && !stack.addressValid
|
|
? "Not an address yet — it needs a prefix, like " + stack.modelData.addressHint
|
|
: "The address this machine takes on the network, with its prefix length"
|
|
placeholder: String(stack.modelData.addressHint)
|
|
text: stack.draftAddress
|
|
enabled: !NetworkTools.busy
|
|
onAccepted: value => stack.draftAddress = value.trim()
|
|
}
|
|
|
|
TextFieldRow {
|
|
width: parent.width
|
|
visible: stack.draftMethod === "manual"
|
|
label: "Gateway"
|
|
detail: "The router traffic leaves through. Leave it empty on a segment with no way out."
|
|
placeholder: String(stack.modelData.gatewayHint)
|
|
text: stack.draftGateway
|
|
enabled: !NetworkTools.busy
|
|
onAccepted: value => stack.draftGateway = value.trim()
|
|
}
|
|
|
|
TextFieldRow {
|
|
width: parent.width
|
|
visible: stack.draftMethod === "manual"
|
|
label: "DNS"
|
|
detail: "Nameservers, separated by commas. These replace the ones the network hands out rather than joining them."
|
|
placeholder: String(stack.modelData.dnsHint)
|
|
text: stack.draftDns
|
|
enabled: !NetworkTools.busy
|
|
onAccepted: value => stack.draftDns = value.trim()
|
|
}
|
|
|
|
ActionRow {
|
|
width: parent.width
|
|
visible: stack.dirty
|
|
label: "Apply " + String(stack.modelData.label)
|
|
detail: {
|
|
if (stack.draftMethod === "manual" && !stack.addressValid)
|
|
return "Fill in an address with a prefix first — nothing is written until this looks like an address.";
|
|
if (stack.draftMethod === "auto")
|
|
return "Clears the static address and takes what the network offers. This connection reconnects.";
|
|
return "Writes these to this connection only, and reconnects it.";
|
|
}
|
|
action: NetworkTools.busy ? "Working…" : "Apply"
|
|
enabled: !NetworkTools.busy
|
|
&& (stack.draftMethod === "auto" || stack.addressValid)
|
|
divider: !stack.six
|
|
onTriggered: stack.apply()
|
|
}
|
|
}
|
|
}
|
|
}
|