Skip to content

Commit ca87bb4

Browse files
committed
fix(network): enforce one active link and persist ethernet-off intent
1 parent 10144fb commit ca87bb4

4 files changed

Lines changed: 137 additions & 151 deletions

File tree

‎BlocksScreen/lib/network/worker.py‎

Lines changed: 99 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -284,11 +284,10 @@ def hotspot_password(self) -> str:
284284
async def _async_initialize(self) -> None:
285285
"""Bootstrap the worker on the asyncio thread.
286286
287-
Detects network interfaces, enforces the boot-time ethernet/Wi-Fi
288-
mutual exclusion, activates any saved VLANs if ethernet is present,
289-
triggers an initial Wi-Fi scan, and starts all D-Bus signal listeners.
290-
Emits ``initialized`` when done (even on failure, so the manager can
291-
unblock its caller).
287+
Detects network interfaces, activates any saved VLANs if ethernet is
288+
present, triggers an initial Wi-Fi scan, and starts all D-Bus signal
289+
listeners. Emits ``initialized`` when done (even on failure, so the
290+
manager can unblock its caller).
292291
"""
293292
try:
294293
if not self._system_bus:
@@ -297,7 +296,6 @@ async def _async_initialize(self) -> None:
297296

298297
self._running = True
299298
await self._detect_interfaces()
300-
await self._enforce_boot_mutual_exclusion()
301299

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

370-
async def _enforce_boot_mutual_exclusion(self) -> None:
371-
"""Disable Wi-Fi at boot if ethernet is already connected.
368+
async def _set_wired_profiles_autoconnect(self, enabled: bool) -> None:
369+
"""Persist autoconnect on every wired profile; Device.Autoconnect dies on NM restart."""
370+
try:
371+
paths = await self._nm_settings().list_connections()
372+
for path, settings in await self._gather_settings(list(paths)):
373+
conn = settings.get("connection", {})
374+
if conn.get("type", (None, ""))[1] != "802-3-ethernet":
375+
continue
376+
if bool(conn.get("autoconnect", ("b", True))[1]) == enabled:
377+
continue
378+
props = {k: dict(v) for k, v in settings.items()}
379+
props["connection"]["autoconnect"] = ("b", enabled)
380+
props["connection"].pop("timestamp", None)
381+
await self._conn_settings(path).update(props)
382+
logger.info("Wired profile %s autoconnect -> %s", path, enabled)
383+
except Exception as exc:
384+
logger.warning("Wired profile autoconnect (%s) failed: %s", enabled, exc)
385+
386+
async def _ensure_wired_autoconnect(self) -> None:
387+
"""Re-arm wired autoconnect on both the device and the saved profiles.
372388
373-
Prevents the device from simultaneously using both interfaces at
374-
startup. If ethernet is active and the Wi-Fi radio is on, the Wi-Fi
375-
device is disconnected and the radio is disabled, then we wait up to
376-
8 s for the radio to confirm it is off. Failures are logged but not
377-
propagated — a non-fatal best-effort action at boot.
389+
Called only when the user asks for ethernet, so autoconnect staying off
390+
keeps meaning "user turned it off". Best-effort: never propagates.
378391
"""
392+
if not self._primary_wired_path:
393+
return
394+
await self._set_wired_profiles_autoconnect(True)
379395
try:
380-
if not await self._is_ethernet_connected():
381-
return
382-
if not await self._nm().wireless_enabled:
383-
return
384-
logger.info("Boot: ethernet active + Wi-Fi enabled — disabling Wi-Fi")
385-
if self._primary_wifi_path:
386-
try:
387-
await self._wifi().disconnect()
388-
except Exception as exc:
389-
logger.debug("Pre-radio-disable disconnect ignored: %s", exc)
390-
await self._nm().wireless_enabled.set_async(False)
391-
await self._wait_for_wifi_radio(False, timeout=8.0)
392-
self._is_hotspot_active = False
396+
wired = self._generic(self._primary_wired_path)
397+
if not await wired.autoconnect:
398+
await wired.autoconnect.set_async(True)
393399
except Exception as exc:
394-
logger.warning("Boot mutual exclusion failed (non-fatal): %s", exc)
400+
logger.debug("Device autoconnect re-arm ignored: %s", exc)
395401

396402
async def _start_signal_listeners(self) -> None:
397403
"""Create persistent proxies and spawn all D-Bus signal listeners.
@@ -1295,9 +1301,7 @@ async def _add_network_impl(
12951301
if not self._primary_wifi_path or not self._system_bus:
12961302
return ConnectionResult(False, "No Wi-Fi interface", "no_interface")
12971303

1298-
if await self._is_known(ssid):
1299-
await self._delete_network_impl(ssid)
1300-
self._invalidate_saved_cache()
1304+
backup = await self._backup_and_drop_existing(ssid)
13011305

13021306
try:
13031307
await self._wifi().request_scan({})
@@ -1337,15 +1341,7 @@ async def _add_network_impl(
13371341
try:
13381342
await self._nm().activate_connection(conn_path)
13391343
if not await self._wait_for_connection(ssid, timeout=_WIFI_CONNECT_TIMEOUT):
1340-
await self._delete_network_impl(ssid)
1341-
self._invalidate_saved_cache()
1342-
return ConnectionResult(
1343-
False,
1344-
f"Authentication failed for '{ssid}'.\n"
1345-
"The saved profile has been removed.\n"
1346-
"Please check the password and try again.",
1347-
"auth_failed",
1348-
)
1344+
return await self._rollback_failed_add(ssid, backup)
13491345
return ConnectionResult(True, f"Network '{ssid}' added and connecting")
13501346
except Exception as act_err:
13511347
logger.warning("Activate after add failed: %s", act_err)
@@ -1360,13 +1356,65 @@ async def _reload_connections(self) -> None:
13601356
except Exception as reload_err:
13611357
logger.debug("reload_connections non-fatal: %s", reload_err)
13621358

1359+
async def _backup_profile(self, ssid: str) -> dict | None:
1360+
"""Snapshot a saved profile's settings plus secrets so it can be re-added."""
1361+
conn_path = await self._get_connection_path(ssid)
1362+
if not conn_path:
1363+
return None
1364+
try:
1365+
cs = self._conn_settings(conn_path)
1366+
settings = dict(await cs.get_settings())
1367+
await self._merge_wifi_secrets(cs, settings)
1368+
settings.get("connection", {}).pop("timestamp", None)
1369+
logger.debug("backup_profile: '%s' sections=%s", ssid, sorted(settings))
1370+
return settings
1371+
except Exception as exc:
1372+
logger.warning("backup_profile: could not snapshot '%s': %s", ssid, exc)
1373+
return None
1374+
1375+
async def _restore_profile(self, ssid: str, settings: dict) -> bool:
1376+
"""Re-add a backed-up profile after a failed replacement; True when restored."""
1377+
try:
1378+
await self._nm_settings().add_connection(settings)
1379+
self._invalidate_saved_cache()
1380+
logger.info("restore_profile: '%s' restored after failed add", ssid)
1381+
return True
1382+
except Exception as exc:
1383+
logger.error("restore_profile: could not restore '%s': %s", ssid, exc)
1384+
return False
1385+
1386+
async def _rollback_failed_add(
1387+
self, ssid: str, backup: dict | None
1388+
) -> ConnectionResult:
1389+
"""Delete the profile that never activated and restore *backup* if there is one."""
1390+
logger.warning("add_network: '%s' never activated, rolling back", ssid)
1391+
await self._delete_network_impl(ssid)
1392+
self._invalidate_saved_cache()
1393+
if backup and await self._restore_profile(ssid, backup):
1394+
return ConnectionResult(
1395+
False,
1396+
f"Could not connect to '{ssid}'.\n"
1397+
"The previously saved password was kept.\n"
1398+
"Please check the password and try again.",
1399+
"auth_failed",
1400+
)
1401+
return ConnectionResult(
1402+
False,
1403+
f"Authentication failed for '{ssid}'.\n"
1404+
"The saved profile has been removed.\n"
1405+
"Please check the password and try again.",
1406+
"auth_failed",
1407+
)
1408+
13631409
async def _backup_and_drop_existing(self, ssid: str) -> dict | None:
1364-
"""Return the scanned AP properties for *ssid*, or None if it is not visible."""
1365-
ap_paths = await self._wifi().get_all_access_points()
1366-
for _ap_path, props in await self._gather_ap_properties(ap_paths):
1367-
if self._decode_ssid(props.get("ssid", b"")) == ssid:
1368-
return props
1369-
return None
1410+
"""Back up and delete a saved profile for *ssid* so it can be re-added cleanly."""
1411+
if not await self._is_known(ssid):
1412+
return None
1413+
backup = await self._backup_profile(ssid)
1414+
logger.info("add_network: replacing saved '%s' (backup=%s)", ssid, bool(backup))
1415+
await self._delete_network_impl(ssid)
1416+
self._invalidate_saved_cache()
1417+
return backup
13701418

13711419
async def _async_connect_network(self, ssid: str) -> None:
13721420
"""Activate an existing saved Wi-Fi profile and emit connection_result."""
@@ -1592,16 +1640,13 @@ async def _update_network_impl(
15921640
return ConnectionResult(False, str(exc), "update_failed")
15931641

15941642
async def _async_set_wifi_enabled(self, enabled: bool) -> None:
1595-
"""Enable or disable the Wi-Fi radio, handling ethernet mutual exclusion."""
1643+
"""Enable or disable the Wi-Fi radio. Ethernet is left untouched."""
15961644
try:
15971645
if not self._system_bus:
15981646
return
15991647
if not enabled:
16001648
self._is_hotspot_active = False
16011649

1602-
if enabled and await self._is_ethernet_connected():
1603-
await self._async_disconnect_ethernet()
1604-
16051650
current = await self._nm().wireless_enabled
16061651
if current != enabled:
16071652
if not enabled:
@@ -1651,24 +1696,18 @@ async def _async_disconnect_ethernet(self) -> None:
16511696
logger.error("Failed to disconnect ethernet: %s", exc)
16521697

16531698
async def _async_connect_ethernet(self) -> None:
1654-
"""Disable Wi-Fi/hotspot, activate the wired device, and restore saved VLANs."""
1699+
"""Activate the wired device and restore saved VLANs.
1700+
1701+
Mechanism only: the one-link-at-a-time policy lives in the UI toggles.
1702+
"""
16551703
if not self._primary_wired_path:
16561704
self.error_occurred.emit("connect_ethernet", "No wired device found")
16571705
return
16581706
try:
16591707
if self._is_hotspot_active:
16601708
await self._async_toggle_hotspot(False)
16611709

1662-
if self._primary_wifi_path:
1663-
try:
1664-
await self._wifi().disconnect()
1665-
except Exception as exc:
1666-
logger.debug("Pre-VLAN disconnect ignored: %s", exc)
1667-
await asyncio.sleep(0.5)
1668-
1669-
if await self._nm().wireless_enabled:
1670-
await self._nm().wireless_enabled.set_async(False)
1671-
await self._wait_for_wifi_radio(False, timeout=8.0)
1710+
await self._ensure_wired_autoconnect()
16721711

16731712
await self._nm().activate_connection("/", self._primary_wired_path, "/")
16741713
await asyncio.sleep(1.5)

‎BlocksScreen/lib/panels/networkWindow.py‎

Lines changed: 30 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from lib.utils.icon_button import IconButton
3838
from lib.utils.list_model import EntryDelegate, EntryListModel, ListItem
3939
from PyQt6 import QtCore, QtGui, QtWidgets
40+
from PyQt6.QtCore import QTimer
4041

4142
logger = logging.getLogger(__name__)
4243

@@ -264,7 +265,7 @@ def _on_reconnect_complete(self) -> None:
264265

265266
def _init_timers(self) -> None:
266267
"""Initialize timers."""
267-
self._load_timer = QtCore.QTimer(self)
268+
self._load_timer = QTimer(self)
268269
self._load_timer.setSingleShot(True)
269270
self._load_timer.timeout.connect(self._handle_load_timeout)
270271

@@ -573,7 +574,7 @@ def _on_operation_complete(self, result: ConnectionResult) -> None:
573574
result.message,
574575
)
575576
ssid = self._target_ssid
576-
QtCore.QTimer.singleShot(
577+
QTimer.singleShot(
577578
2000, lambda _ssid=ssid: self._nm.connect_network(_ssid)
578579
)
579580
return # Keep loading visible; state machine handles completion
@@ -1007,6 +1008,15 @@ def _configure_info_box_centered(self) -> None:
10071008
self.mn_info_box.setWordWrap(True)
10081009
self.mn_info_box.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
10091010

1011+
def _claim_link(self, winner) -> None:
1012+
"""Force the other two link toggles OFF; only one link may be on at a time."""
1013+
for btn in (self.wifi_button, self.hotspot_button, self.ethernet_button):
1014+
if btn is winner:
1015+
continue
1016+
toggle = btn.toggle_button
1017+
with QtCore.QSignalBlocker(toggle):
1018+
toggle.state = toggle.State.OFF
1019+
10101020
@QtCore.pyqtSlot(object, name="stateChange")
10111021
def _on_toggle_state(self, new_state) -> None:
10121022
"""Route a toggle-button state change to the correct handler (Wi-Fi or hotspot)."""
@@ -1027,71 +1037,58 @@ def _on_toggle_state(self, new_state) -> None:
10271037
# when the worker emits the disconnected state.
10281038

10291039
def _handle_wifi_toggle(self, is_on: bool) -> None:
1030-
"""Enable or disable Wi-Fi, enforcing the ethernet/hotspot mutual-exclusion rule."""
1040+
"""Enable or disable Wi-Fi; turning it on drops the hotspot and the cable."""
10311041
if not is_on:
10321042
self._target_ssid = None
10331043
self._pending_operation = PendingOperation.WIFI_OFF
10341044
self._set_loading_state(True)
10351045
self._nm.set_wifi_enabled(False)
10361046
return
10371047

1038-
hotspot_btn = self.hotspot_button.toggle_button
1039-
eth_btn = self.ethernet_button.toggle_button
1040-
with QtCore.QSignalBlocker(hotspot_btn):
1041-
hotspot_btn.state = hotspot_btn.State.OFF
1042-
with QtCore.QSignalBlocker(eth_btn):
1043-
eth_btn.state = eth_btn.State.OFF
1048+
# Guard before touching links: a state update mid-teardown bounces the toggle.
1049+
self._target_ssid = None
1050+
self._pending_operation = PendingOperation.WIFI_ON
1051+
self._set_loading_state(True)
10441052

1053+
self._claim_link(self.wifi_button)
1054+
self._nm.disconnect_ethernet()
10451055
self._nm.set_wifi_enabled(True)
10461056

1047-
# NOTE: set_wifi_enabled is dispatched to the worker — cached state
1048-
# is STALE here (may still show ethernet). Always proceed to the
1049-
# saved-network connection path.
1050-
10511057
saved = self._nm.saved_networks
10521058
wifi_networks = [n for n in saved if "ap" not in n.mode]
10531059

10541060
if not wifi_networks:
1061+
self._clear_loading()
10551062
self._show_warning_popup("No saved Wi-Fi networks. Please add one first.")
10561063
self._display_wifi_on_no_connection()
10571064
return
10581065

1059-
# Sort by priority descending (highest priority first),
1060-
# then by timestamp as tiebreaker — this gives "reconnect to
1061-
# highest-priority saved network" behaviour.
1066+
# Reconnect to the highest-priority saved network, newest breaking ties.
10621067
wifi_networks.sort(key=lambda n: (n.priority, n.timestamp), reverse=True)
10631068

10641069
self._target_ssid = wifi_networks[0].ssid
1065-
self._pending_operation = PendingOperation.WIFI_ON
1066-
self._set_loading_state(True)
10671070

10681071
# Non-blocking: disable hotspot then connect
10691072
self._nm.toggle_hotspot(False)
10701073
_ssid_to_connect = self._target_ssid
1071-
QtCore.QTimer.singleShot(
1072-
500, lambda: self._nm.connect_network(_ssid_to_connect)
1073-
)
1074+
QTimer.singleShot(500, lambda: self._nm.connect_network(_ssid_to_connect))
10741075

10751076
def _handle_hotspot_toggle(self, is_on: bool) -> None:
1076-
"""Enable or disable the hotspot, enforcing the ethernet/Wi-Fi mutual-exclusion rule."""
1077+
"""Enable or disable the hotspot; turning it on drops Wi-Fi client and the cable."""
10771078
if not is_on:
10781079
self._target_ssid = None
10791080
self._pending_operation = PendingOperation.HOTSPOT_OFF
10801081
self._set_loading_state(True)
10811082
self._nm.toggle_hotspot(False)
10821083
return
10831084

1084-
wifi_btn = self.wifi_button.toggle_button
1085-
eth_btn = self.ethernet_button.toggle_button
1086-
with QtCore.QSignalBlocker(wifi_btn):
1087-
wifi_btn.state = wifi_btn.State.OFF
1088-
with QtCore.QSignalBlocker(eth_btn):
1089-
eth_btn.state = eth_btn.State.OFF
1090-
10911085
self._target_ssid = None
10921086
self._pending_operation = PendingOperation.HOTSPOT_ON
10931087
self._set_loading_state(True)
10941088

1089+
self._claim_link(self.hotspot_button)
1090+
self._nm.disconnect_ethernet()
1091+
10951092
hotspot_name = self.hotspot_name_input_field.text() or ""
10961093
hotspot_pass = self.hotspot_password_input_field.text() or ""
10971094
hotspot_sec = "wpa-psk"
@@ -1100,18 +1097,14 @@ def _handle_hotspot_toggle(self, is_on: bool) -> None:
11001097
self._nm.create_hotspot(hotspot_name, hotspot_pass, hotspot_sec)
11011098

11021099
def _handle_ethernet_toggle(self, is_on: bool) -> None:
1103-
"""Handle ethernet toggle with mutual exclusion."""
1100+
"""Connect or disconnect the cable; connecting drops Wi-Fi and the hotspot."""
11041101
if is_on:
1105-
wifi_btn = self.wifi_button.toggle_button
1106-
hotspot_btn = self.hotspot_button.toggle_button
1107-
with QtCore.QSignalBlocker(wifi_btn):
1108-
wifi_btn.state = wifi_btn.State.OFF
1109-
with QtCore.QSignalBlocker(hotspot_btn):
1110-
hotspot_btn.state = hotspot_btn.State.OFF
1111-
11121102
self._target_ssid = None
11131103
self._pending_operation = PendingOperation.ETHERNET_ON
11141104
self._set_loading_state(True)
1105+
1106+
self._claim_link(self.ethernet_button)
1107+
self._nm.set_wifi_enabled(False)
11151108
self._nm.connect_ethernet()
11161109
return
11171110

0 commit comments

Comments
 (0)