Files
Modus/src/window/controlcenter/wifi.py
T

764 lines
29 KiB
Python

from fabric.utils import Gdk, GLib, logger
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.separator import Separator
from services.network import NetworkClient
from shared.dialogs.wifi_password_dialog import WiFiPasswordDialog
from shared.widgets.smooth_switch import SmoothSwitch
from shared.window.animated_scrollwindow import AnimatedScrollable
from utils.functions import (
get_wifi_connecting_icon,
get_wifi_icon_for_strength,
spawn_detached,
)
from utils.utils import svg_file
class WifiNetworkSlot(Box):
def __init__(
self, access_point, wifi_service, network_service=None, parent=None, **kwargs
):
super().__init__(name="wifi-network-slot", **kwargs)
self.access_point = access_point
self.wifi_service = wifi_service
self.network_service = network_service
self.parent = parent # Reference to control center
self.ssid = access_point.ssid
self.bssid = access_point.bssid
self.strength = access_point.strength
self.icon_name = access_point.icon
self.is_connected = access_point.is_active
self.styles = [
"connected" if self.is_connected else "",
]
wifi_icon_path = get_wifi_icon_for_strength(self.strength)
self.dimage = svg_file(wifi_icon_path, size=28)
self.wifi_icon_box = Box(
children=[self.dimage],
style_classes=["wifi-icon-box"],
)
if self.is_connected:
self.dimage.add_style_class("connected")
self.network_label = Label(
label=self.ssid, name="wifi-network-name", h_align="start", h_expand=True
)
# Create lock icon for secured networks
self.lock_icon = None
if self.access_point.requires_password:
self.lock_icon = Image(
icon_name="changes-prevent-symbolic",
size=12,
name="wifi-lock-icon",
)
self.password_dialog = None
start_box = Box(
orientation="h",
spacing=8,
)
start_box.children = [self.wifi_icon_box, self.network_label]
if self.lock_icon:
start_box.children.append(self.lock_icon)
self.children = [
Button(
child=start_box,
h_expand=True,
name="wifi-network-button",
on_clicked=lambda *_: self.toggle_connecting(),
)
]
self.on_changed()
def update_ap(self, access_point):
"""Update existing slot with new AP data without recreating it"""
self.access_point = access_point
self.bssid = access_point.bssid
self.strength = access_point.strength
self.is_connected = access_point.is_active
if not self.dimage.has_style_class(
"connecting"
) and not self.dimage.has_style_class("disconnecting"):
from shared.widgets.wifi_icon import get_wifi_icon_for_strength
wifi_icon_path = get_wifi_icon_for_strength(self.strength)
self.dimage.set_from_file(wifi_icon_path)
self.on_changed()
def toggle_connecting(self):
is_currently_connected = self.access_point.is_active
if is_currently_connected:
connecting_icon = get_wifi_connecting_icon()
self.dimage.set_from_file(connecting_icon)
self.dimage.add_style_class("disconnecting")
if self.wifi_service and self.wifi_service._device:
spawn_detached(
[
"nmcli",
"device",
"disconnect",
self.wifi_service._device.get_iface(),
]
)
self.is_connected = False
GLib.timeout_add(500, lambda: self._reset_disconnect_state())
else:
is_saved = self.network_service and self.network_service.is_network_saved(
self.ssid
)
if self.access_point.requires_password and not is_saved:
self._show_password_dialog()
else:
# Try to connect without password (for open or saved networks)
connecting_icon = get_wifi_connecting_icon()
self.dimage.set_from_file(connecting_icon)
self.dimage.add_style_class("connecting")
def on_open_connection_result(success, message):
"""Handle the connection result for open networks"""
if success:
self.is_connected = True
# Remove connecting state after a short delay
GLib.timeout_add(500, lambda: self._reset_connect_state())
else:
# Connection failed
self._reset_connect_state()
# If password was wrong or auth failed, prompt the user
if message == "Wrong password":
self._show_password_dialog()
# Update display after connection attempt
self.on_changed()
try:
if self.network_service:
self.network_service.connect_wifi_bssid(
self.access_point.bssid, callback=on_open_connection_result
)
except Exception as e:
logger.error(
f"[WiFi] Failed to connect to {self.access_point.ssid}: {e}"
)
self._reset_connect_state()
self.on_changed()
self.on_changed()
def _reset_disconnect_state(self):
"""Reset visual state after disconnect operation"""
self.dimage.remove_style_class("disconnecting")
wifi_icon_path = get_wifi_icon_for_strength(self.strength)
self.dimage.set_from_file(wifi_icon_path)
self.on_changed()
return False
def _reset_connect_state(self):
"""Reset visual state after connect operation"""
self.dimage.remove_style_class("connecting")
wifi_icon_path = get_wifi_icon_for_strength(self.strength)
self.dimage.set_from_file(wifi_icon_path)
self.on_changed()
return False
def on_changed(self, *_):
self.is_connected = self.access_point.is_active
self.styles = [
"connected" if self.is_connected else "",
]
if self.is_connected:
self.wifi_icon_box.add_style_class("wifi-icon-box-connected")
else:
self.wifi_icon_box.remove_style_class("wifi-icon-box-connected")
return
def _show_password_dialog(self):
"""Show the WiFi password dialog"""
if self.parent and hasattr(self.parent, "hide_controlcenter"):
self.parent.hide_controlcenter()
if self.password_dialog:
self.password_dialog.destroy_dialog()
self.password_dialog = WiFiPasswordDialog(
ssid=self.ssid,
on_connect_callback=self._on_password_connect,
on_cancel_callback=self._on_password_cancel,
)
self.password_dialog.show_dialog()
def _on_password_connect(self, ssid, password):
"""Handle password dialog connect action"""
if password.strip():
connecting_icon = get_wifi_connecting_icon()
self.dimage.set_from_file(connecting_icon)
self.dimage.add_style_class("connecting")
def on_connection_result(success, message):
"""Handle the connection result"""
if success:
self.is_connected = True
GLib.timeout_add(500, lambda: self._reset_connect_state())
if (
self.password_dialog
and self.password_dialog.connection_timeout_id
):
GLib.source_remove(self.password_dialog.connection_timeout_id)
self.password_dialog.connection_timeout_id = None
self.password_dialog.is_connecting = False
else:
self._reset_connect_state()
if self.password_dialog:
self._show_connection_error(message)
self.on_changed()
try:
if self.network_service:
self.network_service.connect_wifi_with_password(
self.access_point.bssid, password, callback=on_connection_result
)
except Exception as e:
logger.error(
f"[WiFi] Failed to initiate connection to {self.access_point.ssid}: {e}"
)
self._reset_connect_state()
if self.password_dialog:
self._show_connection_error("Connection failed. Please try again.")
self.on_changed()
def _show_connection_error(self, message="Incorrect password. Please try again."):
"""Show connection error in a separate thread to prevent UI blocking"""
if self.password_dialog:
self.password_dialog.show_error(message)
return False # Don't repeat if called from GLib.timeout_add
def _on_password_cancel(self):
"""Handle password dialog cancel action"""
self._reset_connect_state()
class WifiConnections(Box):
def __init__(self, parent, show_back_button=True, **kwargs):
super().__init__(
spacing=8,
orientation="vertical",
name="wifi-connections",
**kwargs,
)
self.parent = parent
self.network_service = NetworkClient()
self.wifi_service = None
self.is_scanning = False # Track scanning state
self.refresh_timer = None # Timer for periodic network refresh
self._update_in_progress = False # Prevent concurrent updates
self._destroyed = False # Track if widget is destroyed
self._signal_ids = [] # Track all service signal IDs for cleanup
self._signal_ids.append(
(
self.network_service,
self.network_service.connect("device-ready", self.on_network_ready),
)
)
# Create pull-to-refresh indicator
self.refresh_indicator = Label(
name="wifi-refresh-indicator",
label="↓ Pull to scan for networks",
h_align="center",
visible=False,
style="color: #fff; font-size: 12px; padding: 5px;",
)
title_children = []
if show_back_button:
title_children.append(
Button(
image=Image(icon_name="back", size=10),
on_clicked=lambda *_: self.parent.close_wifi(),
)
)
title_children.append(Label("Wi-Fi", name="wifi-title"))
self.title = Box(
orientation="h",
children=title_children,
)
self.toggle_button = SmoothSwitch(
name="toggle-button",
on_user_toggle=lambda active: self.on_toggle_changed(
self.toggle_button, active
),
)
# Create Known Network section
self.known_networks_label = Label(
label="Known Network", h_align="start", name="networks-title"
)
self.known_networks = Box(
spacing=4, orientation="vertical", name="known-networks"
)
self.known_networks_scrolled = AnimatedScrollable(
min_content_size=(303, 0),
max_content_size=(303, 200),
child=self.known_networks,
overlay_scroll=True,
animate=False,
)
# Create "No networks available" message
self.no_networks_label = Label(
label="No networks available",
h_align="center",
name="no-networks-label",
visible=False,
)
self.other_networks_button = Button(
child=CenterBox(
start_children=Label("Other Networks", h_align="start"),
end_children=self.refresh_indicator,
),
name="wifi-other-button",
on_clicked=self.toggle_other_networks,
)
self.other_networks = Box(spacing=4, orientation="vertical")
# Create scrolled window for other networks
self.other_networks_scrolled = AnimatedScrollable(
min_content_size=(303, 0),
max_content_size=(303, 300),
child=self.other_networks,
overlay_scroll=True,
animate=False,
)
self.other_networks.set_visible(False)
# Add pull-to-refresh functionality to scrolled window
self.setup_pull_to_refresh()
# Create More Settings button (same style as Other Networks button)
self.more_settings_button = Button(
child=Label("More Settings", h_align="start"),
name="wifi-other-button",
on_clicked=self.open_network_settings,
)
self.children = [
CenterBox(
start_children=self.title,
end_children=self.toggle_button,
name="wifi-widget-top",
),
Separator(orientation="h", name="separator"),
self.known_networks_label,
self.known_networks_scrolled,
self.no_networks_label,
Separator(orientation="h", name="separator"),
self.other_networks_button,
self.other_networks_scrolled,
Separator(orientation="h", name="separator"),
self.more_settings_button,
]
self.connect("destroy", self.on_destroy)
self.connect("unmap", self.on_hide)
self.connect("map", lambda *_: self.start_network_monitoring())
def on_hide(self, *_):
"""Called when the widget is hidden (popup closed)"""
self.stop_network_monitoring()
self._cancel_pending_refresh()
if self.other_networks.get_visible():
self.other_networks.set_visible(False)
self.other_networks_scrolled.snap_to_size(0)
def toggle_other_networks(self, *_):
"""Toggle the visibility of other networks section"""
current_state = self.other_networks.get_visible()
self._cancel_pending_refresh()
if current_state:
self.other_networks.set_visible(False)
self.other_networks_scrolled.snap_to_size(0)
else:
self.other_networks.set_visible(True)
if self.wifi_service:
self.wifi_service.scan()
# Defer refresh until after the height animation completes
GLib.idle_add(self._refresh_after_animation)
def _cancel_pending_refresh(self):
if hasattr(self, "_anim_finished_handler") and self._anim_finished_handler:
try:
self.other_networks_scrolled.height_animator.disconnect(
self._anim_finished_handler
)
except Exception:
pass
self._anim_finished_handler = None
def _refresh_after_animation(self):
if self._destroyed:
return False
anim = self.other_networks_scrolled.height_animator
if anim.playing:
self._anim_finished_handler = anim.connect(
"finished",
lambda *_: (
self.force_network_refresh() if not self._destroyed else None
),
)
else:
self.force_network_refresh()
return False
def on_network_ready(self, *_):
"""Called when network service is ready"""
self.wifi_service = self.network_service.wifi_device
if self.wifi_service:
self.toggle_button.set_active(self.wifi_service.enabled)
self._signal_ids.append(
(
self.wifi_service,
self.wifi_service.connect(
"notify::enabled", self.on_wifi_enabled_changed
),
)
)
self._signal_ids.append(
(
self.wifi_service,
self.wifi_service.connect("changed", self.update_networks),
)
)
# Initial network update
self.update_networks()
def on_toggle_changed(self, toggle_button, *_):
"""Handle WiFi toggle button changes"""
if self.wifi_service:
new_state = toggle_button.get_active()
self.wifi_service.enabled = new_state
def on_wifi_enabled_changed(self, *_):
"""Handle WiFi enabled state changes"""
if self.wifi_service:
self.toggle_button.set_active(self.wifi_service.enabled)
def open_network_settings(self, *_):
"""Open NetworkManager connection editor"""
try:
spawn_detached(["nm-connection-editor"])
if self.parent and hasattr(self.parent, "hide_controlcenter"):
self.parent.hide_controlcenter()
except FileNotFoundError:
pass
except Exception as e:
logger.error(f"[WiFi] Failed to open network settings: {e}")
def update_networks(self, *_):
"""Update the list of available networks"""
# Prevent concurrent updates and check if destroyed
if self._update_in_progress or self._destroyed or not self.wifi_service:
return
self._update_in_progress = True
try:
current_known_ssids = {
child.ssid
for child in self.known_networks.get_children()
if hasattr(child, "ssid")
}
current_other_ssids = {
child.ssid
for child in self.other_networks.get_children()
if hasattr(child, "ssid")
}
access_points = self.wifi_service.access_points
known_networks = []
other_networks = []
new_known_ssids = set()
new_other_ssids = set()
for access_point in access_points:
try:
if access_point.ssid and access_point.ssid != "Unknown":
if access_point.is_active or self._is_saved_network(
access_point
):
known_networks.append(access_point)
new_known_ssids.add(access_point.ssid)
else:
other_networks.append(access_point)
new_other_ssids.add(access_point.ssid)
except Exception as e:
logger.warning(
f"[WiFi] Failed to process access point {access_point}: {e}"
)
continue
known_changed = current_known_ssids != new_known_ssids
other_changed = current_other_ssids != new_other_ssids
if known_changed or other_changed:
# Get existing networks
existing_known = {
child.ssid: child for child in self.known_networks.get_children()
}
existing_other = {
child.ssid: child for child in self.other_networks.get_children()
}
for access_point in known_networks:
if not self._destroyed:
if access_point.ssid in existing_known:
slot = existing_known.pop(access_point.ssid)
slot.update_ap(access_point)
elif access_point.ssid in existing_other:
slot = existing_other.pop(access_point.ssid)
slot.update_ap(access_point)
self.other_networks.remove(slot)
self.known_networks.add(slot)
else:
network_slot = WifiNetworkSlot(
access_point,
self.wifi_service,
network_service=self.network_service,
parent=self.parent,
)
self.known_networks.add(network_slot)
for access_point in other_networks:
if not self._destroyed:
if access_point.ssid in existing_other:
slot = existing_other.pop(access_point.ssid)
slot.update_ap(access_point)
elif access_point.ssid in existing_known:
slot = existing_known.pop(access_point.ssid)
slot.update_ap(access_point)
self.known_networks.remove(slot)
self.other_networks.add(slot)
else:
network_slot = WifiNetworkSlot(
access_point,
self.wifi_service,
network_service=self.network_service,
parent=self.parent,
)
self.other_networks.add(network_slot)
for slot in existing_known.values():
if hasattr(slot, "access_point"):
slot.strength = 0
from shared.widgets.wifi_icon import get_wifi_icon_for_strength
if not slot.dimage.has_style_class(
"connecting"
) and not slot.dimage.has_style_class("disconnecting"):
slot.dimage.set_from_file(get_wifi_icon_for_strength(0))
for slot in existing_other.values():
if hasattr(slot, "access_point"):
slot.strength = 0
from shared.widgets.wifi_icon import get_wifi_icon_for_strength
if not slot.dimage.has_style_class(
"connecting"
) and not slot.dimage.has_style_class("disconnecting"):
slot.dimage.set_from_file(get_wifi_icon_for_strength(0))
if not self._destroyed:
has_known_networks = len(known_networks) > 0
has_other_networks = len(other_networks) > 0
has_any_networks = has_known_networks or has_other_networks
self.known_networks_scrolled.set_visible(True)
self.no_networks_label.set_visible(not has_any_networks)
self.other_networks_button.set_visible(True) # Always visible
self.refresh_network_states()
except Exception as e:
logger.error(f"[WiFi] Error during update_networks: {e}")
finally:
self._update_in_progress = False
def _is_saved_network(self, access_point):
"""Check if a network is saved/known using NetworkManager connections"""
if not self.network_service:
return False
ssid = access_point.ssid
if not ssid or ssid == "Unknown":
return False
return self.network_service.is_network_saved(ssid)
def refresh_network_states(self, *_):
"""Refresh connection states for all network slots"""
for child in self.known_networks.get_children():
if hasattr(child, "on_changed"):
child.on_changed()
for child in self.other_networks.get_children():
if hasattr(child, "on_changed"):
child.on_changed()
def start_network_monitoring(self):
"""Start periodic monitoring for network changes"""
self.refresh_timer = GLib.timeout_add_seconds(5, self.periodic_network_refresh)
def stop_network_monitoring(self):
"""Stop periodic monitoring"""
if self.refresh_timer:
GLib.source_remove(self.refresh_timer)
self.refresh_timer = None
def periodic_network_refresh(self):
"""Periodically refresh network list to catch external connections"""
if self._destroyed:
self.refresh_timer = None
return False
if (
self._update_in_progress
or not self.wifi_service
or not self.wifi_service.enabled
):
return True # Continue monitoring
try:
self.update_networks()
except Exception as e:
logger.error(f"[WiFi] Error during periodic network refresh: {e}")
return True # Continue monitoring
def force_network_refresh(self):
"""Force an immediate refresh of the network list"""
if self._update_in_progress or self._destroyed:
return
try:
self.update_networks()
except Exception as e:
logger.error(f"[WiFi] Error during forced network refresh: {e}")
def setup_pull_to_refresh(self):
"""Setup pull-to-refresh gesture for the scrolled window"""
self.vadjustment = self.other_networks_scrolled.get_vadjustment()
# Track gesture state
self.pull_start_y = 0
self.is_pulling = False
self.pull_threshold = 50 # pixels to trigger refresh
# Connect to scroll events
self.other_networks_scrolled.connect("scroll-event", self.on_scroll_event)
self.other_networks_scrolled.connect("button-press-event", self.on_button_press)
self.other_networks_scrolled.connect(
"button-release-event", self.on_button_release
)
self.other_networks_scrolled.connect(
"motion-notify-event", self.on_motion_notify
)
# Enable events
self.other_networks_scrolled.set_events(
Gdk.EventMask.SCROLL_MASK
| Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.BUTTON_RELEASE_MASK
| Gdk.EventMask.POINTER_MOTION_MASK
)
def on_scroll_event(self, widget, event):
"""Handle scroll events for pull-to-refresh"""
if self.vadjustment.get_value() <= 0:
if event.direction == Gdk.ScrollDirection.UP:
if self.wifi_service:
self.wifi_service.scan()
self.force_network_refresh()
return True
return False
def on_button_press(self, widget, event):
"""Handle button press for touch/drag gestures"""
if self.vadjustment.get_value() <= 0:
self.pull_start_y = event.y
self.is_pulling = True
return False
def on_button_release(self, widget, event):
"""Handle button release for touch/drag gestures"""
if self.is_pulling:
pull_distance = event.y - self.pull_start_y
if pull_distance > self.pull_threshold:
if self.wifi_service:
self.wifi_service.scan()
self.force_network_refresh()
self.refresh_indicator.set_visible(False)
self.refresh_indicator.remove_style_class("ready-to-refresh")
self.is_pulling = False
return False
def on_motion_notify(self, widget, event):
"""Handle motion events for visual feedback during pull"""
if self.is_pulling and self.vadjustment.get_value() <= 0:
pull_distance = event.y - self.pull_start_y
if pull_distance > 0:
self.refresh_indicator.set_visible(True)
if pull_distance >= self.pull_threshold:
self.refresh_indicator.set_label("↑ Release to scan")
self.refresh_indicator.add_style_class("ready-to-refresh")
else:
self.refresh_indicator.set_label("↓ Pull to scan for networks")
self.refresh_indicator.remove_style_class("ready-to-refresh")
else:
self.refresh_indicator.set_visible(False)
return False
def on_destroy(self, widget):
"""Cleanup when widget is destroyed"""
self._destroyed = True
self.stop_network_monitoring()
self._cancel_pending_refresh()
for obj, sig_id in self._signal_ids:
try:
obj.disconnect(sig_id)
except Exception as e:
logger.error(f"[WiFi] Failed to disconnect signal {sig_id}: {e}")
self._signal_ids.clear()
self.wifi_service = None
self.network_service = None
def close_wifi(self):
"""Called when WiFi panel is being closed"""
if self.other_networks.get_visible():
self.other_networks.set_visible(False)
self.other_networks_scrolled.snap_to_size(0)