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:
@@ -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())
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user