// 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() } } } }