Own the network: details, VPN, enterprise Wi-Fi, and a firewall that can also allow
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -16,6 +16,21 @@ Column {
|
||||
|
||||
spacing: 0
|
||||
|
||||
// Connected and paired devices always render (they lead the sort, so the
|
||||
// slice keeps them); unpaired strangers fill up to the cap and the rest
|
||||
// wait behind the "nearby devices" row. Discovery in a busy room finds
|
||||
// dozens of phones and TVs that are not yours.
|
||||
property bool showAll: false
|
||||
readonly property int visibleCap: 5
|
||||
readonly property var shown: {
|
||||
const list = Connectivity.bluetoothDevices;
|
||||
if (root.showAll || list.length <= root.visibleCap)
|
||||
return list;
|
||||
const pinned = list.filter(device => device.connected || device.paired).length;
|
||||
return list.slice(0, Math.max(root.visibleCap, pinned));
|
||||
}
|
||||
readonly property int hiddenCount: Connectivity.bluetoothDevices.length - root.shown.length
|
||||
|
||||
function primaryAction(device: var): void {
|
||||
if (device.connected) {
|
||||
device.disconnect();
|
||||
@@ -41,7 +56,7 @@ Column {
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Connectivity.bluetoothDevices
|
||||
model: root.shown
|
||||
|
||||
SettingRow {
|
||||
id: entry
|
||||
@@ -52,7 +67,8 @@ Column {
|
||||
width: parent.width
|
||||
label: entry.modelData.name || entry.modelData.address || "Unknown device"
|
||||
detail: root.stateLabel(entry.modelData)
|
||||
divider: entry.index < Connectivity.bluetoothDevices.length - 1
|
||||
divider: entry.index < root.shown.length - 1
|
||||
|| root.hiddenCount > 0 || root.showAll
|
||||
controlWidth: 200
|
||||
activatable: !entry.modelData.pairing
|
||||
onActivated: root.primaryAction(entry.modelData)
|
||||
@@ -81,6 +97,19 @@ Column {
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: root.hiddenCount > 0
|
||||
|| (root.showAll && Connectivity.bluetoothDevices.length > root.visibleCap)
|
||||
label: root.showAll
|
||||
? "Show fewer devices"
|
||||
: root.hiddenCount + (root.hiddenCount === 1 ? " more nearby device" : " more nearby devices")
|
||||
detail: root.showAll ? "" : "Unpaired devices in range, folded to keep the list short"
|
||||
activatable: true
|
||||
onActivated: root.showAll = !root.showAll
|
||||
divider: false
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: Connectivity.bluetoothDevices.length === 0
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// The facts about one connection that otherwise need a terminal.
|
||||
//
|
||||
// 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.
|
||||
// They are read-only here: editing them properly means static addressing, which
|
||||
// is a page of its own rather than four fields smuggled into a details drawer.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
// { ip4, gateway, dns: [], mac, macRandomized } 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
|
||||
|
||||
// [{ 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
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,18 @@
|
||||
// Connections — wired, Wi-Fi, and Bluetooth.
|
||||
// Connections — the whole network, not the half that was easy.
|
||||
//
|
||||
// Wi-Fi and Bluetooth are handled here rather than delegated. Everything goes
|
||||
// through Quickshell.Networking and Quickshell.Bluetooth -- NetworkManager and
|
||||
// BlueZ over DBus -- and nothing shells out to nmcli or bluetoothctl. That was
|
||||
// the founding requirement for this desktop: never having to drop to a terminal
|
||||
// to join a network.
|
||||
// This page used to end in a card headed "Owned by Fedora" with two doors back
|
||||
// to GNOME's panels: one for hidden and enterprise networks, one for VPN and
|
||||
// proxies. Everything behind those doors now lives here, so the card is gone.
|
||||
//
|
||||
// Two mechanisms, deliberately kept apart:
|
||||
//
|
||||
// * Wi-Fi and Bluetooth state -- scanning, joining, pairing, the radio
|
||||
// switches -- go through Quickshell.Networking and Quickshell.Bluetooth,
|
||||
// which speak to NetworkManager and BlueZ over DBus. Connectivity.qml is
|
||||
// pinned shell-out-free and stays that way.
|
||||
// * Everything NetworkManager exposes only through nmcli -- per-connection
|
||||
// addresses, forgetting a profile, MAC randomization, VPN import, hotspot,
|
||||
// enterprise join -- goes through NetworkTools.qml and scripts/panama-network.
|
||||
//
|
||||
// Scanning follows this page being on screen. Wi-Fi scanning and especially
|
||||
// Bluetooth discovery hold the radio, and doing either for a list nobody is
|
||||
@@ -19,26 +27,78 @@ import qs.services
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
|
||||
objectName: "connectivity"
|
||||
title: "Connections"
|
||||
lede: Connectivity.activeNetwork
|
||||
? "Connected to " + Connectivity.activeNetwork.name
|
||||
: "Wi-Fi, Bluetooth, and the things Fedora owns."
|
||||
lede: {
|
||||
const wifi = Connectivity.activeNetwork ? Connectivity.activeNetwork.name : "";
|
||||
if (wifi !== "" && Connectivity.wiredOn)
|
||||
return "On " + wifi + " and wired.";
|
||||
if (wifi !== "")
|
||||
return "On " + wifi + ".";
|
||||
if (Connectivity.wiredOn)
|
||||
return "Wired.";
|
||||
return "Wi-Fi, Bluetooth, VPN, and what this machine can reach.";
|
||||
}
|
||||
|
||||
// Drive the scanners only while this page is the one being shown.
|
||||
// The wired connection's own drawer, and the two inline forms that are only
|
||||
// open while someone is filling them in.
|
||||
property bool wiredOpen: false
|
||||
property bool hotspotOpen: false
|
||||
property string hotspotName: ""
|
||||
property bool importOpen: false
|
||||
property string importPath: ""
|
||||
|
||||
readonly property string wiredConnection:
|
||||
String(Connectivity.wiredDevice?.network?.name ?? "")
|
||||
|
||||
readonly property string activeWifi:
|
||||
Connectivity.activeNetwork ? Connectivity.activeNetwork.name : ""
|
||||
|
||||
// Drive the scanners and the helper only while this page is the one being
|
||||
// shown. Both cost radio time or nmcli invocations for a list nobody is
|
||||
// reading.
|
||||
Component.onCompleted: {
|
||||
Connectivity.active = true;
|
||||
NetworkTools.active = true;
|
||||
if (!WifiShare.scanned)
|
||||
WifiShare.refresh();
|
||||
Vpn.refresh();
|
||||
}
|
||||
Component.onDestruction: Connectivity.active = false
|
||||
Component.onDestruction: {
|
||||
Connectivity.active = false;
|
||||
NetworkTools.active = false;
|
||||
}
|
||||
|
||||
// Addresses and the MAC in use both change with the connection, so the
|
||||
// cached details for a network that just came up are stale the moment it
|
||||
// does.
|
||||
onActiveWifiChanged: {
|
||||
if (root.activeWifi !== "")
|
||||
NetworkTools.refreshDetails(root.activeWifi);
|
||||
}
|
||||
onWiredConnectionChanged: {
|
||||
if (root.wiredConnection !== "")
|
||||
NetworkTools.refreshDetails(root.wiredConnection);
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: NetworkTools.lastError !== ""
|
||||
label: "The network needs attention"
|
||||
detail: NetworkTools.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// ── Wired ────────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Wired"
|
||||
visible: Connectivity.wiredDevice !== null
|
||||
|
||||
SwitchRow {
|
||||
label: "Ethernet"
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
|
||||
label: root.wiredConnection !== "" ? root.wiredConnection : "Ethernet"
|
||||
// Three states worth telling apart: on, off but plugged in, and
|
||||
// nothing in the socket. "Not connected" covered all three and
|
||||
// explained none of them.
|
||||
@@ -54,13 +114,44 @@ SettingsPage {
|
||||
// that blamed the hardware for what it had just done itself.
|
||||
return device.name + " · off";
|
||||
}
|
||||
checked: Connectivity.wiredOn
|
||||
enabled: Connectivity.wiredAvailable
|
||||
controlWidth: 78
|
||||
divider: false
|
||||
onToggled: value => Connectivity.setWired(value)
|
||||
activatable: root.wiredConnection !== "" && Connectivity.wiredOn
|
||||
onActivated: root.wiredOpen = !root.wiredOpen
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 9
|
||||
|
||||
SettingsToggle {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Connectivity.wiredOn
|
||||
enabled: Connectivity.wiredAvailable
|
||||
onToggled: value => Connectivity.setWired(value)
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: root.wiredConnection !== "" && Connectivity.wiredOn
|
||||
text: root.wiredOpen ? "▴" : "▾"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ConnectionDetails {
|
||||
width: parent.width
|
||||
visible: root.wiredOpen && Connectivity.wiredOn
|
||||
details: root.wiredConnection !== ""
|
||||
? NetworkTools.detailsFor(root.wiredConnection) : null
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wi-Fi ────────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Wi-Fi"
|
||||
// A Wi-Fi switch reading "On" above the words "No Wi-Fi adapter" is a
|
||||
@@ -79,7 +170,7 @@ SettingsPage {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: Connectivity.wifiEnabled
|
||||
enabled: Connectivity.wifiAvailable
|
||||
onToggled: value => Networking.wifiEnabled = value
|
||||
onToggled: value => Connectivity.setWifiEnabled(value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,71 +178,188 @@ SettingsPage {
|
||||
width: parent.width
|
||||
visible: Connectivity.wifiEnabled
|
||||
}
|
||||
}
|
||||
|
||||
// Sharing a network by QR, the way GNOME's Wi-Fi panel does. The
|
||||
// alternative is reading a passphrase out loud.
|
||||
//
|
||||
// The image holds the password in machine-readable form, so it is generated
|
||||
// on demand rather than up front, and the helper writes it to tmpfs under
|
||||
// XDG_RUNTIME_DIR instead of anywhere persistent.
|
||||
SettingsCard {
|
||||
visible: Connectivity.wifiDevice !== null && WifiShare.shareable.length > 0
|
||||
title: "Share a network"
|
||||
subtitle: WifiShare.sharing !== ""
|
||||
? "Point a phone's camera at the code to join " + WifiShare.sharing + "."
|
||||
: "Shows a QR code a phone can scan to join, without reading the password out."
|
||||
// ── Hotspot ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// One radio cannot be a client and an access point at the same time, so
|
||||
// starting this drops whatever network the machine is on. Said before it
|
||||
// happens rather than discovered when the browser stops loading.
|
||||
|
||||
Repeater {
|
||||
model: WifiShare.shareable
|
||||
|
||||
ActionRow {
|
||||
id: shareRow
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
label: shareRow.modelData.ssid
|
||||
detail: WifiShare.sharing === shareRow.modelData.name
|
||||
? "Showing a code below — anyone who can see the screen can join"
|
||||
: "Saved network"
|
||||
action: WifiShare.sharing === shareRow.modelData.name ? "Hide" : "Show code"
|
||||
divider: shareRow.index < WifiShare.shareable.length - 1 || WifiShare.sharing !== ""
|
||||
onTriggered: WifiShare.sharing === shareRow.modelData.name
|
||||
? WifiShare.stopSharing()
|
||||
: WifiShare.share(shareRow.modelData.name)
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
visible: Connectivity.wifiEnabled && !NetworkTools.hotspotActive
|
||||
label: "Hotspot"
|
||||
detail: "Share this machine's connection over Wi-Fi"
|
||||
action: root.hotspotOpen ? "Cancel" : "Start hotspot…"
|
||||
enabled: !NetworkTools.busy
|
||||
divider: root.hotspotOpen
|
||||
onTriggered: {
|
||||
root.hotspotOpen = !root.hotspotOpen;
|
||||
root.hotspotName = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Drawn at its natural size on a white plate: a QR code inverted or
|
||||
// tinted to match a dark theme is unreliable to scan, and this one has
|
||||
// exactly one job.
|
||||
Item {
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: WifiShare.sharing !== "" && WifiShare.imagePath !== ""
|
||||
implicitHeight: visible ? plate.height + 20 : 0
|
||||
visible: root.hotspotOpen && !NetworkTools.hotspotActive
|
||||
|
||||
Rectangle {
|
||||
id: plate
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
y: 10
|
||||
width: 208
|
||||
height: 208
|
||||
radius: 10
|
||||
color: "white"
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
label: "Network name"
|
||||
detail: "What the hotspot calls itself to phones and laptops nearby"
|
||||
placeholder: "panama-hotspot"
|
||||
text: root.hotspotName
|
||||
enabled: !NetworkTools.busy
|
||||
onAccepted: value => root.hotspotName = value
|
||||
}
|
||||
|
||||
Image {
|
||||
anchors.centerIn: parent
|
||||
width: 184
|
||||
height: 184
|
||||
smooth: false
|
||||
fillMode: Image.PreserveAspectFit
|
||||
cache: false
|
||||
source: WifiShare.imagePath !== "" ? "file://" + WifiShare.imagePath : ""
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
label: "Start the hotspot"
|
||||
detail: Connectivity.activeNetwork
|
||||
? "This machine leaves " + Connectivity.activeNetwork.name
|
||||
+ " while the hotspot runs — one radio cannot do both."
|
||||
: "NetworkManager makes up a password and shows it once."
|
||||
action: NetworkTools.busy ? "Starting…" : "Start"
|
||||
enabled: root.hotspotName.trim() !== "" && !NetworkTools.busy
|
||||
divider: false
|
||||
onTriggered: {
|
||||
NetworkTools.startHotspot(root.hotspotName.trim());
|
||||
root.hotspotOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: NetworkTools.hotspotActive
|
||||
label: "Hotspot is running"
|
||||
detail: "Phones and laptops nearby can see this network and join it"
|
||||
value: NetworkTools.hotspotSsid
|
||||
}
|
||||
|
||||
// Shown once, on purpose. NetworkManager keeps the passphrase; this page
|
||||
// never stores it, so leaving this screen means asking NetworkManager
|
||||
// again rather than reading it back from Panama.
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: NetworkTools.hotspotActive && NetworkTools.hotspotPassword !== ""
|
||||
label: "Password"
|
||||
detail: "Shown once. Panama does not keep a copy — write it down or let someone type it in now."
|
||||
value: NetworkTools.hotspotPassword
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
visible: NetworkTools.hotspotActive
|
||||
label: "Stop the hotspot"
|
||||
detail: "Anything connected through this machine loses its connection"
|
||||
action: "Stop"
|
||||
enabled: !NetworkTools.busy
|
||||
divider: false
|
||||
onTriggered: NetworkTools.stopHotspot()
|
||||
}
|
||||
}
|
||||
|
||||
// ── VPN ──────────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "VPN"
|
||||
subtitle: "WireGuard and OpenVPN profiles NetworkManager holds for you."
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: Vpn.lastError !== ""
|
||||
label: "The last VPN action did not finish"
|
||||
detail: Vpn.lastError
|
||||
value: ""
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Vpn.connections
|
||||
|
||||
delegate: SwitchRow {
|
||||
id: vpnRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: parent.width
|
||||
label: String(vpnRow.modelData.name ?? "")
|
||||
detail: String(vpnRow.modelData.kind ?? "VPN")
|
||||
+ (vpnRow.modelData.active === true ? " · connected" : "")
|
||||
checked: vpnRow.modelData.active === true
|
||||
enabled: !Vpn.busy
|
||||
onToggled: value => Vpn.setActive(String(vpnRow.modelData.uuid ?? ""), value)
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: Vpn.scanned && Vpn.connections.length === 0
|
||||
label: "No VPNs configured"
|
||||
detail: "Import a WireGuard or OpenVPN file to add one"
|
||||
value: ""
|
||||
}
|
||||
|
||||
// Names the profile that appeared rather than saying "done". An import
|
||||
// that succeeds under a name you did not choose is otherwise invisible
|
||||
// until you go looking for it in the list.
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: NetworkTools.lastImport !== ""
|
||||
label: "Imported " + NetworkTools.lastImport
|
||||
detail: "It is switched off until you turn it on above"
|
||||
value: ""
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
label: "Import a VPN"
|
||||
detail: "A .conf file from WireGuard, or a .ovpn file from OpenVPN"
|
||||
action: root.importOpen ? "Cancel" : "Import…"
|
||||
enabled: !NetworkTools.busy
|
||||
divider: root.importOpen
|
||||
onTriggered: {
|
||||
root.importOpen = !root.importOpen;
|
||||
root.importPath = "";
|
||||
}
|
||||
}
|
||||
|
||||
// A typed path rather than a file chooser this phase. A chooser is the
|
||||
// better answer and is worth doing properly; a typed path is worth far
|
||||
// more than the door back to GNOME it replaces.
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.importOpen
|
||||
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
label: "File"
|
||||
detail: "The full path to the profile you were given"
|
||||
placeholder: "~/Downloads/work.ovpn"
|
||||
text: root.importPath
|
||||
enabled: !NetworkTools.busy
|
||||
onAccepted: value => root.importPath = value
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
label: "Import this profile"
|
||||
detail: "NetworkManager reads it and adds a profile. Nothing connects until you switch it on."
|
||||
action: NetworkTools.busy ? "Importing…" : "Import"
|
||||
enabled: root.importPath.trim() !== "" && !NetworkTools.busy
|
||||
divider: false
|
||||
onTriggered: {
|
||||
NetworkTools.importVpn(root.importPath.trim());
|
||||
root.importOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bluetooth ────────────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Bluetooth"
|
||||
visible: Connectivity.adapter !== null
|
||||
@@ -159,54 +367,119 @@ SettingsPage {
|
||||
|
||||
SettingRow {
|
||||
label: "Bluetooth"
|
||||
detail: Connectivity.adapter
|
||||
? (Connectivity.adapter.enabled ? "On" : "Off")
|
||||
detail: Connectivity.bluetoothAvailable
|
||||
? (Connectivity.bluetoothEnabled ? "On" : "Off")
|
||||
: "Unavailable"
|
||||
controlWidth: 48
|
||||
divider: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||
divider: Connectivity.bluetoothEnabled
|
||||
|
||||
SettingsToggle {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
checked: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||
enabled: Connectivity.adapter !== null
|
||||
onToggled: value => {
|
||||
if (Connectivity.adapter)
|
||||
Connectivity.adapter.enabled = value;
|
||||
}
|
||||
checked: Connectivity.bluetoothEnabled
|
||||
enabled: Connectivity.bluetoothAvailable
|
||||
onToggled: value => Connectivity.setBluetoothEnabled(value)
|
||||
}
|
||||
}
|
||||
|
||||
BluetoothPanel {
|
||||
width: parent.width
|
||||
visible: !!(Connectivity.adapter && Connectivity.adapter.enabled)
|
||||
visible: Connectivity.bluetoothEnabled
|
||||
}
|
||||
}
|
||||
|
||||
// ── Radios and proxy ─────────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
title: "Owned by Fedora"
|
||||
// These two panels drive NetworkManager over D-Bus, which is why they
|
||||
// work in this session when most GNOME panels do not. They are split
|
||||
// because GNOME splits them: "network" is wired, VPN and proxies, and
|
||||
// does not contain Wi-Fi -- the one panel used to point everyone
|
||||
// there, so the road to a hidden SSID or eduroam ended on a page
|
||||
// without Wi-Fi on it.
|
||||
subtitle: "Wired, VPN and Wi-Fi connection editing stay with GNOME's panels, which drive the same NetworkManager this page reads. Printers and online accounts have their own pages here."
|
||||
title: "Radios and proxy"
|
||||
subtitle: "The two settings that apply to every connection at once."
|
||||
|
||||
ActionRow {
|
||||
label: "Wi-Fi networks"
|
||||
detail: "Hidden networks, enterprise (802.1X) logins, and per-network settings"
|
||||
action: "Open"
|
||||
onTriggered: SystemSettings.openGnomePanel("wifi")
|
||||
// A hardware kill switch cannot be overridden from software, so the
|
||||
// switch says so rather than moving and having nothing happen.
|
||||
SwitchRow {
|
||||
width: parent.width
|
||||
label: "Airplane mode"
|
||||
detail: NetworkTools.airplaneHardBlocked
|
||||
? "A switch on this machine is holding the radios off. Software cannot turn them back on."
|
||||
: "Turns Wi-Fi and Bluetooth off together — the same switch the keyboard's airplane key throws"
|
||||
checked: NetworkTools.airplaneOn
|
||||
enabled: !NetworkTools.busy && !NetworkTools.airplaneHardBlocked
|
||||
onToggled: value => NetworkTools.setAirplane(value)
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
label: "Network connections"
|
||||
detail: "VPN, proxies, and wired connection settings"
|
||||
action: "Open"
|
||||
OptionPickerRow {
|
||||
width: parent.width
|
||||
label: "Network proxy"
|
||||
detail: NetworkTools.proxyMode === "none"
|
||||
? "Applications that honour the system proxy use this. Not every application does."
|
||||
: "In use: " + NetworkTools.proxySummary
|
||||
enabled: !NetworkTools.busy
|
||||
options: [
|
||||
{
|
||||
value: "none",
|
||||
label: "Off",
|
||||
detail: "Applications reach the network directly"
|
||||
},
|
||||
{
|
||||
value: "manual",
|
||||
label: "Manual",
|
||||
detail: "A host and port you enter, used for http, https and socks alike"
|
||||
},
|
||||
{
|
||||
value: "auto",
|
||||
label: "Automatic (PAC)",
|
||||
detail: "A configuration URL decides, per address"
|
||||
}
|
||||
]
|
||||
current: NetworkTools.proxyMode
|
||||
onPicked: value => NetworkTools.setProxyMode(String(value))
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: NetworkTools.proxyMode === "manual"
|
||||
|
||||
// Host and port are written together, because the proxy is only
|
||||
// usable as a pair -- so each field commits with whatever the other
|
||||
// one currently holds, and neither writes a half-configuration.
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
label: "Proxy host"
|
||||
detail: "The machine applications should go through"
|
||||
placeholder: "proxy.example.com"
|
||||
text: NetworkTools.proxyHost
|
||||
enabled: !NetworkTools.busy
|
||||
onAccepted: value => {
|
||||
if (value.trim() !== "" && NetworkTools.proxyPort !== "")
|
||||
NetworkTools.setProxyManual(value.trim(), NetworkTools.proxyPort);
|
||||
}
|
||||
}
|
||||
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
label: "Port"
|
||||
detail: "The port that proxy listens on"
|
||||
placeholder: "8080"
|
||||
text: NetworkTools.proxyPort
|
||||
enabled: !NetworkTools.busy
|
||||
divider: false
|
||||
onAccepted: value => {
|
||||
if (value.trim() !== "" && NetworkTools.proxyHost !== "")
|
||||
NetworkTools.setProxyManual(NetworkTools.proxyHost, value.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
visible: NetworkTools.proxyMode === "auto"
|
||||
label: "Configuration URL"
|
||||
detail: "The .pac file whoever runs the network published"
|
||||
placeholder: "http://example.com/proxy.pac"
|
||||
text: NetworkTools.proxyPac
|
||||
enabled: !NetworkTools.busy
|
||||
divider: false
|
||||
onTriggered: SystemSettings.openGnomePanel("network")
|
||||
onAccepted: value => NetworkTools.setProxyPac(value.trim())
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// Joining an 802.1X network -- eduroam and every corporate SSID -- inline,
|
||||
// under the row you clicked.
|
||||
//
|
||||
// This is the single road that used to end at GNOME's Wi-Fi panel. A PSK field
|
||||
// cannot join these networks: they want an authentication method, an identity,
|
||||
// a password, and sometimes a certificate to check the network against.
|
||||
//
|
||||
// The password never becomes a command-line argument. It is handed to
|
||||
// panama-network over stdin, because argv is world-readable through /proc for
|
||||
// as long as the process lives -- see the pin in network-tools-contract. It is
|
||||
// also dropped from this form the moment the form closes.
|
||||
//
|
||||
// No file dialog for the certificate this phase: a path typed in is worse than
|
||||
// a picker and much better than a road back to GNOME.
|
||||
|
||||
import QtQuick
|
||||
|
||||
Column {
|
||||
id: root
|
||||
|
||||
property string ssid: ""
|
||||
property bool busy: false
|
||||
|
||||
// Live form state. There is no reset(): WifiPanel loads this form and
|
||||
// destroys it when the row closes, so the typed password goes with it
|
||||
// rather than sitting in a hidden object waiting to be reopened.
|
||||
property string eap: "peap-mschapv2"
|
||||
property string identity: ""
|
||||
property string password: ""
|
||||
property string caPath: ""
|
||||
|
||||
readonly property bool ready: root.identity.trim() !== "" && root.password !== ""
|
||||
|
||||
signal submitted(eap: string, identity: string, password: string, caPath: string)
|
||||
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 0
|
||||
|
||||
OptionPickerRow {
|
||||
width: parent.width
|
||||
label: "Authentication"
|
||||
detail: "What the network expects. Whoever runs it publishes this."
|
||||
options: [
|
||||
{
|
||||
value: "peap-mschapv2",
|
||||
label: "PEAP · MSCHAPv2",
|
||||
detail: "The usual choice for university and workplace networks"
|
||||
},
|
||||
{
|
||||
value: "ttls-pap",
|
||||
label: "TTLS · PAP",
|
||||
detail: "For networks built around a plain-text inner method"
|
||||
}
|
||||
]
|
||||
current: root.eap
|
||||
onPicked: value => root.eap = String(value)
|
||||
}
|
||||
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
label: "Identity"
|
||||
detail: "The username the network knows you by, often an email address"
|
||||
placeholder: "[email protected]"
|
||||
text: root.identity
|
||||
enabled: !root.busy
|
||||
onAccepted: value => root.identity = value
|
||||
}
|
||||
|
||||
PasswordRow {
|
||||
width: parent.width
|
||||
label: "Password"
|
||||
detail: "Handed to NetworkManager directly. It is never written to a command line."
|
||||
placeholder: "Password for " + (root.ssid !== "" ? root.ssid : "this network")
|
||||
onChanged: value => root.password = value
|
||||
}
|
||||
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
label: "CA certificate"
|
||||
detail: "Optional. A path to the certificate that proves the network is the one it claims to be."
|
||||
placeholder: "/etc/ssl/certs/example.pem"
|
||||
text: root.caPath
|
||||
enabled: !root.busy
|
||||
onAccepted: value => root.caPath = value
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
label: "Connect to " + (root.ssid !== "" ? root.ssid : "this network")
|
||||
detail: root.ready
|
||||
? "NetworkManager saves this network, so it comes back on its own next time"
|
||||
: "An identity and a password are needed before this network will answer"
|
||||
action: root.busy ? "Connecting…" : "Connect"
|
||||
enabled: root.ready && !root.busy
|
||||
divider: false
|
||||
onTriggered: root.submitted(root.eap, root.identity.trim(), root.password, root.caPath.trim())
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,96 @@ SettingsPage {
|
||||
property string confirmingRemoval: ""
|
||||
property bool confirmingRange: false
|
||||
|
||||
// The add flow. Additions are not destructive -- they open something rather
|
||||
// than closing it -- so there is no confirm here, only the caption saying
|
||||
// the rule is permanent and will prompt.
|
||||
property bool addOpen: false
|
||||
property string addKind: "service"
|
||||
property string addValue: ""
|
||||
|
||||
// Changing a connection's zone IS consequence-bearing: it silently rewrites
|
||||
// what every machine on that network can reach. Two-stage, and the confirm
|
||||
// names the interface it is about to move.
|
||||
property string pendingInterface: ""
|
||||
property string pendingZone: ""
|
||||
|
||||
// The zone being read in the browser. Read-only: this looks at a zone
|
||||
// without applying it to anything.
|
||||
property string browsingZone: ""
|
||||
|
||||
readonly property bool addValid: root.addKind === "service"
|
||||
? /^[a-z0-9][a-z0-9-]*$/.test(root.addValue.trim())
|
||||
: /^[0-9]{1,5}(-[0-9]{1,5})?\/(tcp|udp)$/.test(root.addValue.trim())
|
||||
|
||||
// [{ iface, zone }] -- one row per network interface the firewall has
|
||||
// actually placed in a zone, flattened out of activeZones.
|
||||
readonly property var zonedInterfaces: {
|
||||
const placed = Firewall.activeZones ?? ({});
|
||||
const rows = [];
|
||||
for (const zoneName of Object.keys(placed)) {
|
||||
for (const iface of (placed[zoneName] ?? []))
|
||||
rows.push({ iface: String(iface), zone: String(zoneName) });
|
||||
}
|
||||
rows.sort((a, b) => a.iface.localeCompare(b.iface));
|
||||
return rows;
|
||||
}
|
||||
|
||||
readonly property var zoneOptions: (Firewall.allZones ?? []).map(zoneName => ({
|
||||
value: String(zoneName),
|
||||
label: String(zoneName),
|
||||
detail: String(zoneName) === Firewall.defaultZone
|
||||
? "The default for new connections" : ""
|
||||
}))
|
||||
|
||||
// What an interface is carrying, said the way a person names it: the Wi-Fi
|
||||
// network or the wired profile, falling back to the kernel's name for it.
|
||||
function connectionOn(iface: string): string {
|
||||
if (Connectivity.wifiDevice && Connectivity.wifiDevice.name === iface
|
||||
&& Connectivity.activeNetwork)
|
||||
return Connectivity.activeNetwork.name;
|
||||
if (Connectivity.wiredDevice && Connectivity.wiredDevice.name === iface
|
||||
&& Connectivity.wiredDevice.network)
|
||||
return String(Connectivity.wiredDevice.network.name ?? iface);
|
||||
return iface;
|
||||
}
|
||||
|
||||
// The rules by name, then what the zone does with everything else. The
|
||||
// helper's `summary` counts them; the names are what someone comparing two
|
||||
// zones actually needs, so the names are listed and the helper's own
|
||||
// sentence about the target is kept for the tail.
|
||||
function describeZone(zoneName: string): string {
|
||||
const info = Firewall.zoneInfo(zoneName);
|
||||
if (!info)
|
||||
return "Reading what " + zoneName + " allows…";
|
||||
|
||||
const services = (info.services ?? []).map(entry => String(entry));
|
||||
const ports = (info.ports ?? []).map(entry => String(entry));
|
||||
const parts = [];
|
||||
if (services.length > 0)
|
||||
parts.push(services.join(", "));
|
||||
if (ports.length > 0)
|
||||
parts.push("ports " + ports.join(", "));
|
||||
|
||||
let text = parts.length === 0
|
||||
? zoneName + " allows nothing in."
|
||||
: zoneName + " allows: " + parts.join(", and ") + ".";
|
||||
|
||||
// "3 services, 1 port rule; anything no rule allows is rejected" --
|
||||
// everything after the semicolon is the helper's phrasing for the
|
||||
// zone's target, which the list above does not say.
|
||||
const summary = String(info.summary ?? "");
|
||||
const cut = summary.indexOf("; ");
|
||||
if (cut >= 0)
|
||||
text += " Otherwise, " + summary.slice(cut + 2) + ".";
|
||||
|
||||
const rich = (info.richRules ?? []).length;
|
||||
if (rich > 0)
|
||||
text += " It also carries " + rich + " rich rule"
|
||||
+ (rich === 1 ? "" : "s") + ", which this page never edits.";
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
Component.onCompleted: Firewall.refresh()
|
||||
|
||||
TextRow {
|
||||
@@ -180,7 +270,9 @@ SettingsPage {
|
||||
: "Anything relying on this service stops being reachable.")
|
||||
: "Allowed by name, so it works whatever the port range says"
|
||||
controlWidth: 210
|
||||
divider: serviceRow.index < (Firewall.zone?.services ?? []).length - 1
|
||||
// The add row always follows, so the last service still needs
|
||||
// its hairline.
|
||||
divider: true
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
@@ -208,6 +300,80 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
// ── The add side ─────────────────────────────────────────────────────
|
||||
//
|
||||
// Opening a port is not destructive, so it is not confirmed. It is
|
||||
// permanent and it prompts, and both of those are said before the
|
||||
// button rather than discovered by the polkit dialog appearing.
|
||||
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
label: "Allow something new"
|
||||
detail: "A service firewalld already knows by name, or a port and protocol"
|
||||
action: root.addOpen ? "Cancel" : "Add…"
|
||||
enabled: !Firewall.busy
|
||||
divider: root.addOpen
|
||||
onTriggered: {
|
||||
root.addOpen = !root.addOpen;
|
||||
root.addValue = "";
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: root.addOpen
|
||||
|
||||
SegmentRow {
|
||||
width: parent.width
|
||||
label: "What to allow"
|
||||
detail: "A named service carries its own ports, so it keeps working if they change"
|
||||
controlWidth: 230
|
||||
options: [
|
||||
{ value: "service", label: "Named service" },
|
||||
{ value: "port", label: "Port" }
|
||||
]
|
||||
value: root.addKind
|
||||
enabled: !Firewall.busy
|
||||
onSelected: value => {
|
||||
root.addKind = String(value);
|
||||
root.addValue = "";
|
||||
}
|
||||
}
|
||||
|
||||
TextFieldRow {
|
||||
width: parent.width
|
||||
label: root.addKind === "service" ? "Service name" : "Port and protocol"
|
||||
detail: root.addKind === "service"
|
||||
? "One of firewalld's own service names, in lower case"
|
||||
: "A port or range, then tcp or udp"
|
||||
placeholder: root.addKind === "service" ? "syncthing" : "8080/tcp"
|
||||
text: root.addValue
|
||||
enabled: !Firewall.busy
|
||||
onAccepted: value => root.addValue = value
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
label: root.addValue.trim() === "" || root.addValid
|
||||
? "Allow it"
|
||||
: (root.addKind === "service"
|
||||
? "That is not a service name firewalld would accept"
|
||||
: "That is not a port firewalld would accept")
|
||||
detail: "This writes a permanent rule — the system will ask for your password."
|
||||
action: "Allow"
|
||||
enabled: root.addValid && !Firewall.busy
|
||||
divider: false
|
||||
onTriggered: {
|
||||
if (root.addKind === "service")
|
||||
Firewall.addService(root.addValue.trim());
|
||||
else
|
||||
Firewall.addPort(root.addValue.trim());
|
||||
root.addOpen = false;
|
||||
root.addValue = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shown, never edited.
|
||||
TextRow {
|
||||
visible: (Firewall.zone?.richRules ?? []).length > 0
|
||||
@@ -222,26 +388,176 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
title: "Zones"
|
||||
subtitle: "A zone is a set of rules. Each network connection uses one."
|
||||
subtitle: "A zone is a set of rules. Each network connection uses one, and moving a connection between zones changes what the machines on that network can reach."
|
||||
|
||||
Repeater {
|
||||
model: Object.keys(Firewall.activeZones ?? ({}))
|
||||
model: root.zonedInterfaces
|
||||
|
||||
delegate: Column {
|
||||
id: placement
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string iface: String(placement.modelData.iface ?? "")
|
||||
readonly property string zone: String(placement.modelData.zone ?? "")
|
||||
readonly property bool pending: root.pendingInterface === placement.iface
|
||||
|
||||
width: parent.width
|
||||
label: String(modelData)
|
||||
detail: "Applied to " + (Firewall.activeZones[String(modelData)] ?? []).join(", ")
|
||||
value: String(modelData) === Firewall.defaultZone ? "Default" : ""
|
||||
divider: true
|
||||
|
||||
OptionPickerRow {
|
||||
width: parent.width
|
||||
label: root.connectionOn(placement.iface)
|
||||
detail: root.connectionOn(placement.iface) === placement.iface
|
||||
? "This interface"
|
||||
: "On " + placement.iface
|
||||
enabled: !Firewall.busy
|
||||
options: root.zoneOptions
|
||||
current: placement.zone
|
||||
divider: !placement.pending
|
||||
onPicked: value => {
|
||||
root.pendingInterface = placement.iface;
|
||||
root.pendingZone = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: placement.pending
|
||||
label: "Move " + placement.iface + " to " + root.pendingZone + "?"
|
||||
// The interface is named because that is the thing being
|
||||
// moved, and because "public" on the wrong one is the
|
||||
// difference between a safe café and an unreachable desk.
|
||||
detail: placement.iface + " leaves " + placement.zone + " for "
|
||||
+ root.pendingZone + ". Everything reachable over "
|
||||
+ placement.iface + " is decided by " + root.pendingZone
|
||||
+ " from then on."
|
||||
controlWidth: 210
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: "Keep " + placement.zone
|
||||
enabled: !Firewall.busy
|
||||
onClicked: {
|
||||
root.pendingInterface = "";
|
||||
root.pendingZone = "";
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
text: "Move it"
|
||||
tone: "danger"
|
||||
enabled: !Firewall.busy
|
||||
onClicked: {
|
||||
const target = root.pendingZone;
|
||||
root.pendingInterface = "";
|
||||
root.pendingZone = "";
|
||||
Firewall.setZone(placement.iface, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: Firewall.scanned && root.zonedInterfaces.length === 0
|
||||
label: "No connection is in a zone"
|
||||
detail: !Firewall.available
|
||||
? "The firewall is not running, so nothing has been placed"
|
||||
: "firewalld reports no active zones, which usually means no interface is up"
|
||||
value: ""
|
||||
}
|
||||
|
||||
OptionPickerRow {
|
||||
width: parent.width
|
||||
label: "Default for new connections"
|
||||
detail: "Used when a network does not ask for a particular zone"
|
||||
value: Firewall.defaultZone
|
||||
enabled: !Firewall.busy
|
||||
options: root.zoneOptions
|
||||
current: Firewall.defaultZone
|
||||
onPicked: value => Firewall.setDefaultZone(String(value))
|
||||
}
|
||||
|
||||
// ── The zone browser ─────────────────────────────────────────────────
|
||||
//
|
||||
// Read-only. Picking a chip here applies nothing: it answers "what would
|
||||
// this zone do", which is the question you have to answer before the
|
||||
// dropdowns above are anything but a guess.
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: (Firewall.allZones ?? []).length > 0
|
||||
label: "Browse zones"
|
||||
detail: "What each of the " + (Firewall.allZones ?? []).length
|
||||
+ " zones would allow. Nothing here applies anything."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// Full width rather than in the row's trailing slot: fourteen chips wrap
|
||||
// to several lines, and a row's control area is one line tall.
|
||||
Flow {
|
||||
width: parent.width
|
||||
visible: (Firewall.allZones ?? []).length > 0
|
||||
spacing: 6
|
||||
bottomPadding: 12
|
||||
|
||||
Repeater {
|
||||
model: Firewall.allZones ?? []
|
||||
|
||||
delegate: Rectangle {
|
||||
id: chip
|
||||
|
||||
required property var modelData
|
||||
|
||||
readonly property string zoneName: String(chip.modelData)
|
||||
readonly property bool current: root.browsingZone === chip.zoneName
|
||||
|
||||
width: chipLabel.implicitWidth + 20
|
||||
height: 26
|
||||
radius: Theme.pillRadius
|
||||
color: chip.current
|
||||
? Theme.alpha(Theme.accent, 0.18)
|
||||
: Theme.alpha(Theme.fg, chipHover.hovered ? 0.11 : 0.055)
|
||||
border.width: 1
|
||||
border.color: chip.current
|
||||
? Theme.alpha(Theme.accent, 0.45)
|
||||
: Theme.alpha(Theme.fg, 0.09)
|
||||
|
||||
Text {
|
||||
id: chipLabel
|
||||
anchors.centerIn: parent
|
||||
text: chip.zoneName
|
||||
color: chip.current ? Theme.fg : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
font.weight: chip.current ? Font.DemiBold : Font.Normal
|
||||
}
|
||||
|
||||
HoverHandler {
|
||||
id: chipHover
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
onTapped: root.browsingZone = chip.current ? "" : chip.zoneName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: root.browsingZone !== ""
|
||||
label: root.browsingZone
|
||||
detail: root.describeZone(root.browsingZone)
|
||||
value: root.browsingZone === Firewall.defaultZone ? "Default" : ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -408,6 +408,11 @@ SettingsPage {
|
||||
wrapMode: Text.WordWrap
|
||||
}
|
||||
|
||||
// Lands on System rather than Network. This button used to open the
|
||||
// Network panel as a generic front door, which stopped being true
|
||||
// the moment Connections absorbed VPN, proxies, hotspot and
|
||||
// enterprise Wi-Fi: sending someone to GNOME for a page Panama now
|
||||
// owns is exactly what gnome-handoff-contract exists to catch.
|
||||
SettingsButton {
|
||||
id: gnomeSettingsButton
|
||||
anchors.right: parent.right
|
||||
@@ -416,9 +421,9 @@ SettingsPage {
|
||||
activeFocusOnTab: true
|
||||
border.width: activeFocus ? 2 : 1
|
||||
border.color: activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.08)
|
||||
onClicked: SystemSettings.openGnomePanel("network")
|
||||
Keys.onReturnPressed: SystemSettings.openGnomePanel("network")
|
||||
Keys.onSpacePressed: SystemSettings.openGnomePanel("network")
|
||||
onClicked: SystemSettings.openGnomePanel("system")
|
||||
Keys.onReturnPressed: SystemSettings.openGnomePanel("system")
|
||||
Keys.onSpacePressed: SystemSettings.openGnomePanel("system")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,43 @@ SettingsPage {
|
||||
readonly property bool manualReady: /^(ipp|ipps|socket):\/\/\S+$/.test(root.manualUri)
|
||||
&& root.manualName.trim() !== ""
|
||||
|
||||
// The two defaults a queue can be told to change, dressed for reading. The
|
||||
// values are the helper's closed vocabulary and nothing else reaches
|
||||
// lpadmin; the choices come from what the printer said it supports, so a
|
||||
// queue that cannot print two-sided never grows a two-sided dropdown.
|
||||
//
|
||||
// Empty while get-options is still on its way, which is why the rows are
|
||||
// gated on the list rather than on the current value: an empty dropdown
|
||||
// above a printer is worse than no dropdown at all.
|
||||
function mediaChoices(reported: var): var {
|
||||
const detail = {
|
||||
"Letter": "8.5 × 11 in",
|
||||
"A4": "210 × 297 mm",
|
||||
"Legal": "8.5 × 14 in"
|
||||
};
|
||||
return (reported?.choices?.media ?? []).map(choice => ({
|
||||
value: String(choice),
|
||||
label: String(choice),
|
||||
detail: detail[String(choice)] ?? ""
|
||||
}));
|
||||
}
|
||||
|
||||
function sidesChoices(reported: var): var {
|
||||
const named = {
|
||||
"one-sided": { label: "Off", detail: "One page per sheet" },
|
||||
"two-sided-long-edge": { label: "Long edge", detail: "Bound like a book" },
|
||||
"two-sided-short-edge": { label: "Short edge", detail: "Bound like a notepad" }
|
||||
};
|
||||
return (reported?.choices?.sides ?? []).map(choice => {
|
||||
const known = named[String(choice)];
|
||||
return {
|
||||
value: String(choice),
|
||||
label: known ? known.label : String(choice),
|
||||
detail: known ? known.detail : ""
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Component.onCompleted: Printers.refresh()
|
||||
|
||||
TextRow {
|
||||
@@ -80,9 +117,18 @@ SettingsPage {
|
||||
}
|
||||
|
||||
Column {
|
||||
id: printerBody
|
||||
|
||||
width: printerBlock.width
|
||||
visible: printerBlock.open
|
||||
|
||||
// What this queue is set to, as CUPS reports it. Null until
|
||||
// the read comes back, and a printer that reports neither
|
||||
// key simply has no dropdowns -- an option offered for a
|
||||
// printer that does not have it is a setting that silently
|
||||
// does nothing.
|
||||
readonly property var reported: Printers.optionsFor(printerBlock.name)
|
||||
|
||||
TextRow {
|
||||
width: printerBlock.width
|
||||
label: "Model"
|
||||
@@ -90,6 +136,28 @@ SettingsPage {
|
||||
value: String(printerBlock.modelData.makeAndModel ?? "Unknown")
|
||||
}
|
||||
|
||||
OptionPickerRow {
|
||||
width: printerBlock.width
|
||||
visible: root.mediaChoices(printerBody.reported).length > 0
|
||||
label: "Paper size"
|
||||
detail: "What applications print onto unless they ask for something else"
|
||||
enabled: !Printers.busy
|
||||
options: root.mediaChoices(printerBody.reported)
|
||||
current: String(printerBody.reported?.options?.media ?? "")
|
||||
onPicked: value => Printers.setOption(printerBlock.name, "media", String(value))
|
||||
}
|
||||
|
||||
OptionPickerRow {
|
||||
width: printerBlock.width
|
||||
visible: root.sidesChoices(printerBody.reported).length > 0
|
||||
label: "Two-sided"
|
||||
detail: "Long edge is the usual choice for text; short edge flips like a notepad"
|
||||
enabled: !Printers.busy
|
||||
options: root.sidesChoices(printerBody.reported)
|
||||
current: String(printerBody.reported?.options?.sides ?? "")
|
||||
onPicked: value => Printers.setOption(printerBlock.name, "sides", String(value))
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: printerBlock.width
|
||||
visible: !printerBlock.modelData.isDefault
|
||||
@@ -173,22 +241,47 @@ SettingsPage {
|
||||
Repeater {
|
||||
model: Printers.jobs
|
||||
|
||||
delegate: ActionRow {
|
||||
delegate: SettingRow {
|
||||
id: jobRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool held: Printers.isHeld(jobRow.modelData)
|
||||
|
||||
width: parent.width
|
||||
label: String(jobRow.modelData.name ?? "Untitled")
|
||||
detail: String(jobRow.modelData.printer ?? "") + " · "
|
||||
+ String(jobRow.modelData.state ?? "")
|
||||
+ (Number(jobRow.modelData.pages ?? 0) > 0
|
||||
? " · " + jobRow.modelData.pages + " pages" : "")
|
||||
action: "Cancel"
|
||||
enabled: !Printers.busy
|
||||
controlWidth: 175
|
||||
divider: jobRow.index < Printers.jobs.length - 1
|
||||
onTriggered: Printers.cancel(Number(jobRow.modelData.id))
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
// Holding is the answer to "stop this one, I have not
|
||||
// finished with it" -- cancelling was the only way out of
|
||||
// the queue before, and it is not reversible.
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: jobRow.held ? "Release" : "Hold"
|
||||
enabled: !Printers.busy
|
||||
onClicked: jobRow.held
|
||||
? Printers.release(Number(jobRow.modelData.id))
|
||||
: Printers.hold(Number(jobRow.modelData.id))
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Cancel"
|
||||
enabled: !Printers.busy
|
||||
onClicked: Printers.cancel(Number(jobRow.modelData.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,42 +294,26 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Nothing set up yet ───────────────────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: Printers.scanned && !Printers.anyPrinters
|
||||
title: "No printers yet"
|
||||
subtitle: "Printers that announce themselves on your network appear here on their own."
|
||||
|
||||
ActionRow {
|
||||
label: "Search the network"
|
||||
detail: Printers.searching
|
||||
? "Listening for printers that announce themselves…"
|
||||
: (Printers.searched
|
||||
? Printers.addable.length + " found"
|
||||
: "Looks for printers over mDNS, the same way phones and laptops find them")
|
||||
action: Printers.searching ? "Searching…" : "Search"
|
||||
enabled: !Printers.searching
|
||||
divider: false
|
||||
onTriggered: Printers.search()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Adding ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// One card, not two. "No printers yet" and "Add a printer" carried the same
|
||||
// Search row with two different wordings, so a machine with no printers
|
||||
// offered the identical button twice under two headings.
|
||||
|
||||
SettingsCard {
|
||||
title: "Add a printer"
|
||||
subtitle: "Driverless printers only. One that needs a manufacturer driver has to be set up with the system printer tool."
|
||||
title: Printers.anyPrinters ? "Add a printer" : "No printers yet"
|
||||
subtitle: Printers.anyPrinters
|
||||
? "Driverless printers only. One that needs a manufacturer driver has to be set up with the system printer tool."
|
||||
: "Printers on this network that speak modern IPP appear here on their own. Driverless only."
|
||||
|
||||
ActionRow {
|
||||
visible: Printers.anyPrinters
|
||||
label: "Search the network"
|
||||
detail: Printers.searching
|
||||
? "Listening for printers that announce themselves…"
|
||||
: (Printers.searched
|
||||
? Printers.addable.length + " printer"
|
||||
+ (Printers.addable.length === 1 ? "" : "s") + " found that are not set up here"
|
||||
: "Looks for printers over mDNS")
|
||||
: "Looks for printers over mDNS, the same way phones and laptops find them")
|
||||
action: Printers.searching ? "Searching…" : "Search"
|
||||
enabled: !Printers.searching
|
||||
onTriggered: Printers.search()
|
||||
|
||||
@@ -22,18 +22,21 @@ SettingsPage {
|
||||
|
||||
Component.onCompleted: Sharing.refresh()
|
||||
|
||||
TextRow {
|
||||
visible: Sharing.lastError !== ""
|
||||
label: "Sharing needs attention"
|
||||
detail: Sharing.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "This machine"
|
||||
subtitle: "The name other machines see."
|
||||
|
||||
// The error belongs to the machine, not to a floating banner above the
|
||||
// title: a bare row above the first card read as part of the page
|
||||
// furniture, which is exactly what an error must not do.
|
||||
TextRow {
|
||||
width: parent.width
|
||||
visible: Sharing.lastError !== ""
|
||||
label: "Sharing needs attention"
|
||||
detail: Sharing.lastError
|
||||
value: ""
|
||||
}
|
||||
|
||||
TextFieldRow {
|
||||
label: "Network name"
|
||||
detail: "Used for ssh and for anything else that finds this machine by name"
|
||||
@@ -47,32 +50,37 @@ SettingsPage {
|
||||
|
||||
SettingsCard {
|
||||
title: "Remote login"
|
||||
subtitle: "Sign in to a terminal on this machine over SSH."
|
||||
subtitle: Sharing.remoteLoginOn
|
||||
? "Sign in to a terminal on this machine with: ssh " + Sharing.networkName
|
||||
: "Sign in to a terminal on this machine over SSH."
|
||||
|
||||
// Port and the password-sign-in claim used to be two rows of their own
|
||||
// below this one. They describe this switch rather than standing beside
|
||||
// it, so they read as its detail line -- and the card is three rows
|
||||
// shorter for saying the same things.
|
||||
//
|
||||
// "Keys only" is reported from sshd's configuration rather than assumed:
|
||||
// claiming it on a machine that actually accepts passwords would be a
|
||||
// security claim this page cannot back up.
|
||||
SwitchRow {
|
||||
label: "Allow remote login"
|
||||
detail: Sharing.remoteLogin?.installed === true
|
||||
? (Sharing.remoteLoginOn
|
||||
? "Running, and starts automatically at boot"
|
||||
: "Not running")
|
||||
? "OpenSSH · port " + String(Sharing.remoteLogin?.port ?? "22")
|
||||
+ " · password sign-in: " + Sharing.passwordLoginSummary().toLowerCase()
|
||||
+ (Sharing.remoteLoginOn ? " · starts at boot" : "")
|
||||
: "OpenSSH server is not installed"
|
||||
checked: Sharing.remoteLoginOn
|
||||
enabled: !Sharing.busy && Sharing.remoteLogin?.installed === true
|
||||
divider: Sharing.remoteLoginOn
|
||||
onToggled: value => Sharing.setRemoteLogin(value)
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Sharing.remoteLoginOn
|
||||
label: "Connect with"
|
||||
detail: "From another machine on your network"
|
||||
value: "ssh " + Sharing.networkName
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Sharing.remoteLoginOn && Sharing.remoteSessions.length === 0
|
||||
label: "Nobody is signed in"
|
||||
label: "Nobody is signed in remotely"
|
||||
detail: "Remote login is on, and no one is connected from another machine."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
Repeater {
|
||||
@@ -80,30 +88,14 @@ SettingsPage {
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
required property int index
|
||||
width: parent.width
|
||||
label: String(modelData.user ?? "") + " is signed in from " + String(modelData.from ?? "")
|
||||
detail: "Since " + String(modelData.since ?? "") + " · " + String(modelData.line ?? "")
|
||||
value: ""
|
||||
divider: index < Sharing.remoteSessions.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: Sharing.remoteLoginOn
|
||||
label: "Port"
|
||||
detail: "Where the SSH server is listening"
|
||||
value: String(Sharing.remoteLogin?.port ?? "22")
|
||||
}
|
||||
|
||||
// Reported from the configuration rather than assumed. Saying "keys
|
||||
// only" on a machine that actually accepts passwords would be a
|
||||
// security claim this page cannot back up.
|
||||
TextRow {
|
||||
visible: Sharing.remoteLoginOn
|
||||
label: "Password sign-in"
|
||||
detail: Sharing.passwordLoginSummary()
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
@@ -113,11 +105,12 @@ SettingsPage {
|
||||
SwitchRow {
|
||||
label: "Allow remote desktop"
|
||||
detail: Sharing.remoteDesktop?.available === true
|
||||
? (Sharing.remoteDesktopOn
|
||||
? "Running for your session"
|
||||
? "RDP · port " + String(Sharing.remoteDesktop?.port ?? "3389") + " · "
|
||||
+ (Sharing.remoteDesktopOn
|
||||
? "running for your session"
|
||||
: (Sharing.remoteDesktop?.hasCredentials === true
|
||||
? "Not running"
|
||||
: "Set a username and password before turning this on"))
|
||||
? "not running"
|
||||
: "set a username and password before turning this on"))
|
||||
: "Remote desktop support is not installed"
|
||||
checked: Sharing.remoteDesktopOn
|
||||
enabled: !Sharing.busy
|
||||
@@ -149,12 +142,16 @@ SettingsPage {
|
||||
// terminal, never into this page. grdctl prompts for it on a terminal
|
||||
// and crashes without one, and passing it as an argument would publish
|
||||
// it through /proc to every process on this machine.
|
||||
//
|
||||
// The row says why rather than naming the mechanism: "opens kitty" is
|
||||
// an implementation detail, and the reason -- the password never passes
|
||||
// through Panama -- is the part worth reading.
|
||||
ActionRow {
|
||||
visible: Sharing.remoteDesktop?.available === true
|
||||
label: "Credentials"
|
||||
detail: Sharing.remoteDesktop?.hasCredentials === true
|
||||
? "Stored in the login keyring · setting new ones opens a terminal to type into"
|
||||
: "None stored yet · remote desktop cannot be turned on without them"
|
||||
? "Stored in the login keyring · set in a terminal so the password never passes through Panama"
|
||||
: "None stored yet · set in a terminal so the password never passes through Panama"
|
||||
action: "Set…"
|
||||
enabled: !Sharing.busy
|
||||
onTriggered: Sharing.setRdpCredentials(Quickshell.env("USER") || "")
|
||||
@@ -176,11 +173,15 @@ SettingsPage {
|
||||
title: "File and media sharing"
|
||||
subtitle: "Sharing folders and media needs software this machine does not necessarily have."
|
||||
|
||||
// Not a switch, because there is nothing behind it. Naming what
|
||||
// installing Samba would unlock is the difference between a dead row
|
||||
// and an answer -- the panel this replaces shows a switch that silently
|
||||
// does nothing.
|
||||
TextRow {
|
||||
label: "Share folders on the network"
|
||||
detail: Sharing.fileSharing?.installed === true
|
||||
? "Samba is installed"
|
||||
: "Needs Samba, which is not installed. Settings does not install software."
|
||||
? "Samba is installed, so folders can be published to Windows, macOS and Linux machines alike"
|
||||
: "Samba is not installed — install it and this becomes a switch. Settings does not install software."
|
||||
value: Sharing.fileSharing?.installed === true ? "Available" : "Not installed"
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,26 @@
|
||||
//
|
||||
// The quick settings version is a popover: a compact list you glance at. This
|
||||
// is the one you sit in front of when a network is not behaving, so each row
|
||||
// carries what you would otherwise open a terminal to find out — signal,
|
||||
// carries what you would otherwise open a terminal to find out -- signal,
|
||||
// security, and whether it is a network this machine already knows.
|
||||
//
|
||||
// Joining a secured network reveals an inline password field rather than
|
||||
// failing silently, which is the one interaction the popover already got right
|
||||
// and is worth keeping identical.
|
||||
// The connected network opens: addresses, whether it comes back on its own,
|
||||
// whether this machine shows the same hardware address to it every time, a QR
|
||||
// code for a guest, and the way out. Those five were the whole reason this page
|
||||
// still had a door back to GNOME's Wi-Fi panel.
|
||||
//
|
||||
// Three ways in, and they are genuinely different networks rather than three
|
||||
// styles of the same one:
|
||||
//
|
||||
// * open or already known -- connect, nothing to type;
|
||||
// * WPA with a passphrase -- an inline field, revealed on the row you clicked
|
||||
// rather than in a dialog over a tiled window;
|
||||
// * 802.1X enterprise -- a form, because a passphrase field cannot join
|
||||
// eduroam and pretending otherwise is how people ended up in a terminal.
|
||||
//
|
||||
// Forgetting is two-stage and says what it costs, per the danger pattern the
|
||||
// rest of Settings uses: the arming press changes the row's copy, and only the
|
||||
// second press does anything.
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
@@ -21,25 +35,76 @@ Column {
|
||||
|
||||
spacing: 0
|
||||
|
||||
// SSID whose password field is open, and the last failure.
|
||||
// Exactly one of these is non-empty at a time, so the panel never shows two
|
||||
// ways to join the same network or two networks half-open at once.
|
||||
property string expandedFor: ""
|
||||
property string passwordFor: ""
|
||||
property string enterpriseFor: ""
|
||||
property string confirmingForget: ""
|
||||
|
||||
property string failedSsid: ""
|
||||
property string failedText: ""
|
||||
|
||||
// The list shows what matters and folds the rest: connected and saved
|
||||
// networks always render (they lead the sort, so a slice keeps them), and
|
||||
// strangers fill the remaining slots up to the cap. Everything else waits
|
||||
// behind the "more networks" row -- an apartment building's worth of
|
||||
// neighbors' SSIDs is noise, not a list.
|
||||
property bool showAll: false
|
||||
readonly property int visibleCap: 6
|
||||
readonly property var shown: {
|
||||
const list = Connectivity.networks;
|
||||
if (root.showAll || list.length <= root.visibleCap)
|
||||
return list;
|
||||
const pinned = list.filter(network => network.connected || network.known).length;
|
||||
return list.slice(0, Math.max(root.visibleCap, pinned));
|
||||
}
|
||||
readonly property int hiddenCount: Connectivity.networks.length - root.shown.length
|
||||
|
||||
function isEnterprise(network: var): bool {
|
||||
return Connectivity.securityLabel(network) === "Enterprise";
|
||||
}
|
||||
|
||||
function closeAll(): void {
|
||||
root.expandedFor = "";
|
||||
root.passwordFor = "";
|
||||
root.enterpriseFor = "";
|
||||
root.confirmingForget = "";
|
||||
}
|
||||
|
||||
// One click on a row means whatever that row's state makes it mean. The
|
||||
// connected network opens rather than reconnecting to itself.
|
||||
function activate(network: var): void {
|
||||
root.failedSsid = "";
|
||||
if (network.connected)
|
||||
const name = network.name;
|
||||
|
||||
if (network.connected) {
|
||||
const wasOpen = root.expandedFor === name;
|
||||
root.closeAll();
|
||||
root.expandedFor = wasOpen ? "" : name;
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.isEnterprise(network)) {
|
||||
const wasOpen = root.enterpriseFor === name;
|
||||
root.closeAll();
|
||||
root.enterpriseFor = wasOpen ? "" : name;
|
||||
return;
|
||||
}
|
||||
|
||||
if (network.known || !Connectivity.isSecured(network)) {
|
||||
root.passwordFor = "";
|
||||
root.closeAll();
|
||||
network.connect();
|
||||
return;
|
||||
}
|
||||
root.passwordFor = root.passwordFor === network.name ? "" : network.name;
|
||||
|
||||
const wasOpen = root.passwordFor === name;
|
||||
root.closeAll();
|
||||
root.passwordFor = wasOpen ? "" : name;
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: Connectivity.networks
|
||||
model: root.shown
|
||||
|
||||
Column {
|
||||
id: entry
|
||||
@@ -47,6 +112,23 @@ Column {
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property string ssid: entry.modelData.name || ""
|
||||
readonly property bool open: root.expandedFor === entry.ssid && entry.ssid !== ""
|
||||
readonly property bool joining: root.passwordFor === entry.ssid && entry.ssid !== ""
|
||||
readonly property bool enterprising: root.enterpriseFor === entry.ssid && entry.ssid !== ""
|
||||
readonly property bool confirming: root.confirmingForget === entry.ssid && entry.ssid !== ""
|
||||
|
||||
// Addresses for the connected network, once the helper has answered.
|
||||
// Null means "not read yet", which the details grid renders as such
|
||||
// rather than as "no address".
|
||||
readonly property var details: entry.modelData.connected && entry.ssid !== ""
|
||||
? NetworkTools.detailsFor(entry.ssid) : null
|
||||
|
||||
// The saved profile behind this SSID, if it holds a passphrase a QR
|
||||
// code could carry. Networks with no stored key cannot be shared.
|
||||
readonly property var shareEntry: WifiShare.shareable.find(
|
||||
candidate => String(candidate.ssid ?? "") === entry.ssid) ?? null
|
||||
|
||||
width: parent.width
|
||||
|
||||
SettingRow {
|
||||
@@ -60,12 +142,20 @@ Column {
|
||||
else if (entry.modelData.known)
|
||||
bits.push("Saved");
|
||||
bits.push(Connectivity.signalLabel(entry.modelData.signalStrength));
|
||||
bits.push(Connectivity.securityLabel(entry.modelData));
|
||||
bits.push(root.isEnterprise(entry.modelData)
|
||||
? "Enterprise (802.1X)"
|
||||
: Connectivity.securityLabel(entry.modelData));
|
||||
return bits.join(" · ");
|
||||
}
|
||||
divider: entry.index < Connectivity.networks.length - 1 || root.passwordFor === entry.modelData.name
|
||||
// When the drawer is open its own last row carries the hairline,
|
||||
// so the header does not draw one immediately above it.
|
||||
divider: entry.open
|
||||
? false
|
||||
: (entry.index < root.shown.length - 1
|
||||
|| root.hiddenCount > 0 || root.showAll
|
||||
|| entry.joining || entry.enterprising)
|
||||
controlWidth: 190
|
||||
activatable: !entry.modelData.connected
|
||||
activatable: true
|
||||
onActivated: root.activate(entry.modelData)
|
||||
|
||||
Row {
|
||||
@@ -73,38 +163,162 @@ Column {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 7
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: entry.modelData.connected
|
||||
text: "Connected"
|
||||
color: Theme.accent
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
font.weight: Font.DemiBold
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: entry.modelData.connected
|
||||
text: "Disconnect"
|
||||
onClicked: entry.modelData.disconnect()
|
||||
onClicked: {
|
||||
root.closeAll();
|
||||
entry.modelData.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: !entry.modelData.connected
|
||||
text: entry.modelData.known ? "Connect" : "Join"
|
||||
text: root.isEnterprise(entry.modelData)
|
||||
? (entry.enterprising ? "Cancel" : "Join…")
|
||||
: (entry.modelData.known ? "Connect" : "Join")
|
||||
onClicked: root.activate(entry.modelData)
|
||||
}
|
||||
|
||||
// Only the connected row opens, so only it gets a caret.
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: entry.modelData.connected
|
||||
text: entry.open ? "▴" : "▾"
|
||||
color: Theme.fgMuted
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The password field for this network, when it is the one being
|
||||
// joined. Inline rather than a dialog: a dialog over a tiled window
|
||||
// is a worse place to type than the row you just clicked.
|
||||
// ── The connected network, opened ────────────────────────────────
|
||||
|
||||
Column {
|
||||
width: parent.width
|
||||
visible: entry.open
|
||||
|
||||
ConnectionDetails {
|
||||
width: parent.width
|
||||
details: entry.details
|
||||
}
|
||||
|
||||
// These two describe a saved profile, and until the helper has
|
||||
// answered this panel does not know what the profile says. A
|
||||
// switch drawn on a guess is a switch that lies about the state
|
||||
// it is reporting.
|
||||
SwitchRow {
|
||||
width: parent.width
|
||||
visible: !!entry.details
|
||||
label: "Connect automatically"
|
||||
detail: "Rejoin this network whenever it is in range"
|
||||
checked: entry.details?.autoconnect !== false
|
||||
enabled: !NetworkTools.busy
|
||||
onToggled: value => NetworkTools.setAutoconnect(entry.ssid, value)
|
||||
}
|
||||
|
||||
SwitchRow {
|
||||
width: parent.width
|
||||
visible: !!entry.details
|
||||
label: "Randomize MAC address"
|
||||
detail: entry.details?.macRandomized === true
|
||||
? "This network sees a made-up hardware address, so it cannot follow this machine between visits. Takes effect on the next reconnect."
|
||||
: "Show this network a made-up hardware address instead of the adapter's real one. Takes effect on the next reconnect."
|
||||
checked: entry.details?.macRandomized === true
|
||||
enabled: !NetworkTools.busy
|
||||
onToggled: value => NetworkTools.setMacRandom(entry.ssid, value)
|
||||
}
|
||||
|
||||
ActionRow {
|
||||
width: parent.width
|
||||
visible: entry.shareEntry !== null
|
||||
label: "Share this network"
|
||||
detail: WifiShare.sharing === String(entry.shareEntry?.name ?? "")
|
||||
? "Anyone who can see this screen can join"
|
||||
: "Shows a QR code a phone can scan, so nobody has to read the password out"
|
||||
action: WifiShare.sharing === String(entry.shareEntry?.name ?? "")
|
||||
? "Hide" : "Show code"
|
||||
onTriggered: WifiShare.sharing === String(entry.shareEntry?.name ?? "")
|
||||
? WifiShare.stopSharing()
|
||||
: WifiShare.share(String(entry.shareEntry?.name ?? ""))
|
||||
}
|
||||
|
||||
// Drawn at its natural size on a white plate: a QR code inverted
|
||||
// or tinted to match a dark theme is unreliable to scan, and this
|
||||
// one has exactly one job.
|
||||
Item {
|
||||
width: parent.width
|
||||
visible: entry.shareEntry !== null
|
||||
&& WifiShare.sharing === String(entry.shareEntry?.name ?? "")
|
||||
&& WifiShare.imagePath !== ""
|
||||
implicitHeight: visible ? plate.height + 20 : 0
|
||||
|
||||
Rectangle {
|
||||
id: plate
|
||||
anchors.horizontalCenter: parent.horizontalCenter
|
||||
y: 10
|
||||
width: 208
|
||||
height: 208
|
||||
radius: 10
|
||||
color: "white"
|
||||
|
||||
Image {
|
||||
anchors.centerIn: parent
|
||||
width: 184
|
||||
height: 184
|
||||
smooth: false
|
||||
fillMode: Image.PreserveAspectFit
|
||||
cache: false
|
||||
source: WifiShare.imagePath !== "" ? "file://" + WifiShare.imagePath : ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
label: "Forget this network"
|
||||
detail: entry.confirming
|
||||
? "This disconnects now and deletes the saved password. Rejoining " + entry.ssid + " means typing it again."
|
||||
: "Remove the saved profile for " + entry.ssid
|
||||
controlWidth: 200
|
||||
divider: entry.index < root.shown.length - 1
|
||||
|| root.hiddenCount > 0 || root.showAll
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
text: entry.confirming ? "Keep it" : "Forget…"
|
||||
enabled: !NetworkTools.busy
|
||||
onClicked: root.confirmingForget = entry.confirming ? "" : entry.ssid
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
visible: entry.confirming
|
||||
text: "Forget"
|
||||
tone: "danger"
|
||||
enabled: !NetworkTools.busy
|
||||
onClicked: {
|
||||
root.closeAll();
|
||||
NetworkTools.forget(entry.ssid);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── A passphrase, for the network being joined ───────────────────
|
||||
//
|
||||
// Inline rather than a dialog: a dialog over a tiled window is a
|
||||
// worse place to type than the row you just clicked.
|
||||
|
||||
Item {
|
||||
width: parent.width
|
||||
height: root.passwordFor === entry.modelData.name ? 54 : 0
|
||||
height: entry.joining ? 54 : 0
|
||||
visible: height > 0
|
||||
clip: true
|
||||
|
||||
@@ -141,9 +355,33 @@ Column {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 802.1X, for the networks a passphrase cannot reach ───────────
|
||||
|
||||
// Loaded rather than merely hidden, so closing the form destroys it
|
||||
// and the password that was typed into it. A hidden form keeps its
|
||||
// field, which is both a stale value on the way back in and a
|
||||
// passphrase sitting in a live object for no reason.
|
||||
Loader {
|
||||
id: enterpriseLoader
|
||||
|
||||
width: parent.width
|
||||
active: entry.enterprising
|
||||
visible: enterpriseLoader.active
|
||||
|
||||
sourceComponent: EnterpriseJoinForm {
|
||||
ssid: entry.ssid
|
||||
busy: NetworkTools.busy
|
||||
|
||||
onSubmitted: (eap, identity, secret, caPath) => {
|
||||
NetworkTools.joinEnterprise(entry.ssid, eap, identity, secret, caPath);
|
||||
root.enterpriseFor = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
width: parent.width
|
||||
visible: root.failedSsid === entry.modelData.name
|
||||
visible: root.failedSsid === entry.ssid && entry.ssid !== ""
|
||||
leftPadding: 2
|
||||
bottomPadding: 8
|
||||
text: root.failedText
|
||||
@@ -155,14 +393,28 @@ Column {
|
||||
Connections {
|
||||
target: entry.modelData
|
||||
function onConnectionFailed(reason): void {
|
||||
root.failedSsid = entry.modelData.name;
|
||||
root.failedSsid = entry.ssid;
|
||||
root.failedText = Connectivity.connectionFailureText(reason);
|
||||
root.passwordFor = entry.modelData.name;
|
||||
if (!root.isEnterprise(entry.modelData))
|
||||
root.passwordFor = entry.ssid;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: root.hiddenCount > 0
|
||||
|| (root.showAll && Connectivity.networks.length > root.visibleCap)
|
||||
label: root.showAll
|
||||
? "Show fewer networks"
|
||||
: root.hiddenCount + (root.hiddenCount === 1 ? " more network" : " more networks")
|
||||
detail: root.showAll ? "" : "Weaker signals, folded to keep the list short"
|
||||
activatable: true
|
||||
onActivated: root.showAll = !root.showAll
|
||||
divider: false
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
width: parent.width
|
||||
visible: Connectivity.networks.length === 0
|
||||
|
||||
@@ -83,6 +83,8 @@ GradientSliderRow 1.0 GradientSliderRow.qml
|
||||
OptionPickerRow 1.0 OptionPickerRow.qml
|
||||
WifiPanel 1.0 WifiPanel.qml
|
||||
BluetoothPanel 1.0 BluetoothPanel.qml
|
||||
ConnectionDetails 1.0 ConnectionDetails.qml
|
||||
EnterpriseJoinForm 1.0 EnterpriseJoinForm.qml
|
||||
PasswordField 1.0 PasswordField.qml
|
||||
AudioBalance 1.0 AudioBalance.qml
|
||||
SoundDeviceList 1.0 SoundDeviceList.qml
|
||||
|
||||
Reference in New Issue
Block a user