Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 124 additions & 90 deletions BlocksScreen/lib/network/worker.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import socket as _socket
import struct
import threading
from typing import Callable
from collections.abc import Callable
from uuid import uuid4

import sdbus
Expand Down Expand Up @@ -284,11 +284,13 @@ def hotspot_password(self) -> str:
async def _async_initialize(self) -> None:
"""Bootstrap the worker on the asyncio thread.

Detects network interfaces, enforces the boot-time ethernet/Wi-Fi
mutual exclusion, activates any saved VLANs if ethernet is present,
triggers an initial Wi-Fi scan, and starts all D-Bus signal listeners.
Emits ``initialized`` when done (even on failure, so the manager can
unblock its caller).
Detects network interfaces, activates any saved VLANs if ethernet is
present, triggers an initial Wi-Fi scan, and starts all D-Bus signal
listeners. Emits ``initialized`` when done (even on failure, so the
manager can unblock its caller).

Wired autoconnect is deliberately not re-armed here: NM's latch is what
persists the user's "ethernet off" choice across reboots.
"""
try:
if not self._system_bus:
Expand All @@ -297,7 +299,6 @@ async def _async_initialize(self) -> None:

self._running = True
await self._detect_interfaces()
await self._enforce_boot_mutual_exclusion()

if await self._is_ethernet_connected():
await self._activate_saved_vlans()
Expand Down Expand Up @@ -367,31 +368,39 @@ async def _detect_interfaces(self) -> None:
# Ethernet-only or Wi-Fi driver still loading — log but don't alarm.
logger.warning("No Wi-Fi interface detected; ethernet-only mode")

async def _enforce_boot_mutual_exclusion(self) -> None:
"""Disable Wi-Fi at boot if ethernet is already connected.
async def _set_wired_profiles_autoconnect(self, enabled: bool) -> None:
"""Persist autoconnect on every wired profile; Device.Autoconnect dies on NM restart."""
try:
paths = await self._nm_settings().list_connections()
for path, settings in await self._gather_settings(list(paths)):
conn = settings.get("connection", {})
if conn.get("type", (None, ""))[1] != "802-3-ethernet":
continue
if bool(conn.get("autoconnect", ("b", True))[1]) == enabled:
continue
props = {k: dict(v) for k, v in settings.items()}
props["connection"]["autoconnect"] = ("b", enabled)
props["connection"].pop("timestamp", None)
await self._conn_settings(path).update(props)
logger.info("Wired profile %s autoconnect -> %s", path, enabled)
except Exception as exc:
logger.warning("Wired profile autoconnect (%s) failed: %s", enabled, exc)

async def _ensure_wired_autoconnect(self) -> None:
"""Re-arm wired autoconnect on both the device and the saved profiles.

Prevents the device from simultaneously using both interfaces at
startup. If ethernet is active and the Wi-Fi radio is on, the Wi-Fi
device is disconnected and the radio is disabled, then we wait up to
8 s for the radio to confirm it is off. Failures are logged but not
propagated — a non-fatal best-effort action at boot.
Called only when the user asks for ethernet, so autoconnect staying off
keeps meaning "user turned it off". Best-effort: never propagates.
"""
if not self._primary_wired_path:
return
await self._set_wired_profiles_autoconnect(True)
try:
if not await self._is_ethernet_connected():
return
if not await self._nm().wireless_enabled:
return
logger.info("Boot: ethernet active + Wi-Fi enabled — disabling Wi-Fi")
if self._primary_wifi_path:
try:
await self._wifi().disconnect()
except Exception as exc:
logger.debug("Pre-radio-disable disconnect ignored: %s", exc)
await self._nm().wireless_enabled.set_async(False)
await self._wait_for_wifi_radio(False, timeout=8.0)
self._is_hotspot_active = False
wired = self._generic(self._primary_wired_path)
if not await wired.autoconnect:
await wired.autoconnect.set_async(True)
except Exception as exc:
logger.warning("Boot mutual exclusion failed (non-fatal): %s", exc)
logger.debug("Device autoconnect re-arm ignored: %s", exc)

async def _start_signal_listeners(self) -> None:
"""Create persistent proxies and spawn all D-Bus signal listeners.
Expand Down Expand Up @@ -749,31 +758,12 @@ async def _wait_for_wifi_device_ready(self, timeout: float = 8.0) -> bool:
return False

async def _async_get_current_state(self) -> None:
"""Rebuild and emit the full NetworkState, enforcing runtime mutual exclusion."""
"""Rebuild and emit the full NetworkState. Read-only: never mutates NM."""
try:
if not await self._ensure_dbus_connection():
self.state_changed.emit(NetworkState())
return
state = await self._build_current_state()
if (
state.ethernet_connected
and state.wifi_enabled
and not state.hotspot_enabled
and not self._is_hotspot_active
):
logger.info(
"Runtime mutual exclusion: ethernet active + "
"Wi-Fi — disabling Wi-Fi"
)
if self._primary_wifi_path:
try:
await self._wifi().disconnect()
except Exception as exc:
logger.debug("Disconnect before Wi-Fi disable ignored: %s", exc)
await self._nm().wireless_enabled.set_async(False)
await asyncio.sleep(0.5)
state = await self._build_current_state()
self.state_changed.emit(state)
self.state_changed.emit(await self._build_current_state())
except Exception as exc:
logger.error("Failed to get current state: %s", exc)
self.error_occurred.emit("get_current_state", str(exc))
Expand Down Expand Up @@ -1289,17 +1279,14 @@ async def _add_network_impl(
) -> ConnectionResult:
"""Scan for the SSID, build a connection profile, add it to NM, and activate it.

Deletes any pre-existing profile for the same SSID before adding.
Any pre-existing profile for the same SSID is backed up before being
replaced, and restored if the new credentials fail to activate.
Returns a failed ConnectionResult if the SSID is not visible, the
security type is unsupported, or the 20-second activation wait times out.
"""
if not self._primary_wifi_path or not self._system_bus:
return ConnectionResult(False, "No Wi-Fi interface", "no_interface")

if await self._is_known(ssid):
await self._delete_network_impl(ssid)
self._invalidate_saved_cache()

try:
await self._wifi().request_scan({})
except Exception as exc:
Expand All @@ -1320,10 +1307,14 @@ async def _add_network_impl(
"unsupported_security",
)

# Drop the old profile only once the new one is buildable, so early exits keep it.
backup = await self._backup_and_drop_existing(ssid)
try:
nm_settings = self._nm_settings()
conn_path = await nm_settings.add_connection(conn_props)
except Exception as exc:
if backup:
await self._restore_profile(ssid, backup)
err_str = str(exc).lower()
if "psk" in err_str and ("invalid" in err_str or "property" in err_str):
return ConnectionResult(
Expand All @@ -1338,15 +1329,7 @@ async def _add_network_impl(
try:
await self._nm().activate_connection(conn_path)
if not await self._wait_for_connection(ssid, timeout=_WIFI_CONNECT_TIMEOUT):
await self._delete_network_impl(ssid)
self._invalidate_saved_cache()
return ConnectionResult(
False,
f"Authentication failed for '{ssid}'.\n"
"The saved profile has been removed.\n"
"Please check the password and try again.",
"auth_failed",
)
return await self._rollback_failed_add(ssid, backup)
return ConnectionResult(True, f"Network '{ssid}' added and connecting")
except Exception as act_err:
logger.warning("Activate after add failed: %s", act_err)
Expand All @@ -1361,6 +1344,66 @@ async def _reload_connections(self) -> None:
except Exception as reload_err:
logger.debug("reload_connections non-fatal: %s", reload_err)

async def _backup_profile(self, ssid: str) -> dict | None:
"""Snapshot a saved profile's settings plus secrets so it can be re-added."""
conn_path = await self._get_connection_path(ssid)
if not conn_path:
return None
try:
cs = self._conn_settings(conn_path)
settings = dict(await cs.get_settings())
await self._merge_wifi_secrets(cs, settings)
settings.get("connection", {}).pop("timestamp", None)
logger.debug("backup_profile: '%s' sections=%s", ssid, sorted(settings))
return settings
except Exception as exc:
logger.warning("backup_profile: could not snapshot '%s': %s", ssid, exc)
return None

async def _restore_profile(self, ssid: str, settings: dict) -> bool:
"""Re-add a backed-up profile after a failed replacement; True when restored."""
try:
await self._nm_settings().add_connection(settings)
self._invalidate_saved_cache()
logger.info("restore_profile: '%s' restored after failed add", ssid)
return True
except Exception as exc:
logger.error("restore_profile: could not restore '%s': %s", ssid, exc)
return False

async def _rollback_failed_add(
self, ssid: str, backup: dict | None
) -> ConnectionResult:
"""Delete the profile that never activated and restore *backup* if there is one."""
logger.warning("add_network: '%s' never activated, rolling back", ssid)
await self._delete_network_impl(ssid)
self._invalidate_saved_cache()
if backup and await self._restore_profile(ssid, backup):
return ConnectionResult(
False,
f"Could not connect to '{ssid}'.\n"
"The previously saved password was kept.\n"
"Please check the password and try again.",
"auth_failed",
)
return ConnectionResult(
False,
f"Authentication failed for '{ssid}'.\n"
"The saved profile has been removed.\n"
"Please check the password and try again.",
"auth_failed",
)

async def _backup_and_drop_existing(self, ssid: str) -> dict | None:
"""Back up and delete a saved profile for *ssid* so it can be re-added cleanly."""
if not await self._is_known(ssid):
return None
backup = await self._backup_profile(ssid)
logger.info("add_network: replacing saved '%s' (backup=%s)", ssid, bool(backup))
await self._delete_network_impl(ssid)
self._invalidate_saved_cache()
return backup

async def _async_connect_network(self, ssid: str) -> None:
"""Activate an existing saved Wi-Fi profile and emit connection_result."""
try:
Expand Down Expand Up @@ -1585,16 +1628,13 @@ async def _update_network_impl(
return ConnectionResult(False, str(exc), "update_failed")

async def _async_set_wifi_enabled(self, enabled: bool) -> None:
"""Enable or disable the Wi-Fi radio, handling ethernet mutual exclusion."""
"""Enable or disable the Wi-Fi radio. Ethernet is left untouched."""
try:
if not self._system_bus:
return
if not enabled:
self._is_hotspot_active = False

if enabled and await self._is_ethernet_connected():
await self._async_disconnect_ethernet()

current = await self._nm().wireless_enabled
if current != enabled:
if not enabled:
Expand Down Expand Up @@ -1632,7 +1672,13 @@ async def _async_disconnect_ethernet(self) -> None:
return
try:
await self._deactivate_all_vlans()
await self._wired().disconnect()
try:
await self._wired().disconnect()
except Exception as exc:
# Already inactive is the goal state, not a failure.
if "not active" not in str(exc).lower():
raise
logger.debug("Ethernet already inactive: %s", exc)
loop = asyncio.get_running_loop()
deadline = loop.time() + 4.0
while loop.time() < deadline:
Expand All @@ -1642,26 +1688,23 @@ async def _async_disconnect_ethernet(self) -> None:
logger.info("Ethernet disconnected")
except Exception as exc:
logger.error("Failed to disconnect ethernet: %s", exc)
finally:
# Only user toggles reach here, so record intent even if teardown failed.
await self._set_wired_profiles_autoconnect(False)

async def _async_connect_ethernet(self) -> None:
"""Disable Wi-Fi/hotspot, activate the wired device, and restore saved VLANs."""
"""Activate the wired device and restore saved VLANs.

Mechanism only: the one-link-at-a-time policy lives in the UI toggles.
"""
if not self._primary_wired_path:
self.error_occurred.emit("connect_ethernet", "No wired device found")
return
try:
if self._is_hotspot_active:
await self._async_toggle_hotspot(False)

if self._primary_wifi_path:
try:
await self._wifi().disconnect()
except Exception as exc:
logger.debug("Pre-VLAN disconnect ignored: %s", exc)
await asyncio.sleep(0.5)

if await self._nm().wireless_enabled:
await self._nm().wireless_enabled.set_async(False)
await self._wait_for_wifi_radio(False, timeout=8.0)
await self._ensure_wired_autoconnect()

await self._nm().activate_connection("/", self._primary_wired_path, "/")
await asyncio.sleep(1.5)
Expand Down Expand Up @@ -1695,18 +1738,9 @@ async def _async_create_vlan(
if self._is_hotspot_active:
await self._async_toggle_hotspot(False)

if self._primary_wifi_path:
try:
await self._wifi().disconnect()
except Exception as exc:
logger.debug("Pre-VLAN disconnect ignored: %s", exc)
await asyncio.sleep(0.5)

if await self._nm().wireless_enabled:
await self._nm().wireless_enabled.set_async(False)
await self._wait_for_wifi_radio(False, timeout=8.0)

# A VLAN rides on eth0; Wi-Fi is orthogonal and stays up.
if not await self._is_ethernet_connected():
await self._ensure_wired_autoconnect()
await self._nm().activate_connection("/", self._primary_wired_path, "/")
await asyncio.sleep(1.5)

Expand Down
Loading
Loading