Dismiss WiFi boot prompt when mobile session opens
This commit is contained in:
@@ -1 +1 @@
|
||||
2026-10-01T10:37+08:00
|
||||
2026-10-09T10:26+08:00
|
||||
|
||||
@@ -870,6 +870,7 @@ def create_app(
|
||||
apply_resolved_content, effective_default_content, set_default_content,
|
||||
read_device_status, storage_monitor.get_status, library_order=library_order)
|
||||
mobile_sessions = SessionManager(mobile.dispatch, mobile.identity,
|
||||
on_session_open=network.dismiss_for_boot,
|
||||
wifi_address=lambda: network.get_cached_status().get("active", {}).get("ipv4_address")
|
||||
if network.get_cached_status().get("active", {}).get("connected") else None)
|
||||
mobile_runtime = BluezRuntime(mobile_sessions, enabled=os.environ.get("MATRIX_BLE_ENABLED") == "1")
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
from .protocol import Handshake, ProtocolError, Reassembler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RpcError(Exception):
|
||||
def __init__(self, code, message):
|
||||
@@ -23,10 +26,12 @@ def checked_name(value):
|
||||
|
||||
|
||||
class SessionManager:
|
||||
def __init__(self, dispatch, identity, *, clock=time.monotonic, wifi_address=lambda: None):
|
||||
def __init__(self, dispatch, identity, *, clock=time.monotonic, wifi_address=lambda: None,
|
||||
on_session_open=None):
|
||||
self.dispatch = dispatch
|
||||
self.identity = identity
|
||||
self.clock = clock
|
||||
self.on_session_open = on_session_open
|
||||
self.lock = threading.RLock()
|
||||
self.owner = None
|
||||
self.receiver = None
|
||||
@@ -116,6 +121,13 @@ class SessionManager:
|
||||
self.client_name = name
|
||||
self.opened = True
|
||||
self.generation += 1
|
||||
if self.on_session_open is not None:
|
||||
try:
|
||||
self.on_session_open()
|
||||
except Exception:
|
||||
# A failed display notification must not invalidate
|
||||
# an already established encrypted session.
|
||||
logger.exception("Mobile session-open notification failed")
|
||||
elif method == "session.ping":
|
||||
result = dict(alive=True)
|
||||
elif method == "session.rename":
|
||||
|
||||
@@ -112,6 +112,7 @@ class WifiNetworkService:
|
||||
self.monotonic = monotonic
|
||||
self.poll_seconds = poll_seconds
|
||||
self._lock = threading.RLock()
|
||||
self._indicator_lock = threading.RLock()
|
||||
self.configuration_lock = self._lock
|
||||
self.settings_revision = str(uuid4())
|
||||
self._stop = threading.Event()
|
||||
@@ -156,13 +157,14 @@ class WifiNetworkService:
|
||||
self.display.dismiss_wifi_indicator()
|
||||
|
||||
def dismiss_for_boot(self) -> None:
|
||||
try:
|
||||
changed = self.store.dismiss_for_boot()
|
||||
except Exception:
|
||||
logger.exception("Failed to persist WiFi indicator dismissal")
|
||||
changed = True
|
||||
if changed or self.display.get_status()["state"].get("wifi_indicator_active"):
|
||||
self.display.dismiss_wifi_indicator()
|
||||
with self._indicator_lock:
|
||||
try:
|
||||
changed = self.store.dismiss_for_boot()
|
||||
except Exception:
|
||||
logger.exception("Failed to persist WiFi indicator dismissal")
|
||||
changed = True
|
||||
if changed or self.display.get_status()["state"].get("wifi_indicator_active"):
|
||||
self.display.dismiss_wifi_indicator()
|
||||
|
||||
def get_status(self, *, include_secret: bool = False) -> dict[str, Any]:
|
||||
managed_uuid = self.store.config["managed_connection_uuid"]
|
||||
@@ -342,6 +344,8 @@ class WifiNetworkService:
|
||||
self._stop.wait(self.poll_seconds)
|
||||
|
||||
def _refresh_indicator(self) -> None:
|
||||
if self.store.session["dismissed"]:
|
||||
return
|
||||
managed_uuid = self.store.config["managed_connection_uuid"]
|
||||
try:
|
||||
active = self.backend.read_active()
|
||||
@@ -350,7 +354,7 @@ class WifiNetworkService:
|
||||
f"SSID: {active['ssid']} "
|
||||
f"IP: http://{active['ipv4_address']}:8080/"
|
||||
)
|
||||
self.display.show_wifi_indicator(connected=True, message=message)
|
||||
self._show_indicator_unless_dismissed(connected=True, message=message)
|
||||
return
|
||||
|
||||
if managed_uuid:
|
||||
@@ -359,6 +363,13 @@ class WifiNetworkService:
|
||||
message = f"SSID: {saved.get('ssid') or '未配置'} 密码: {password}"
|
||||
else:
|
||||
message = "尚未配置 WiFi"
|
||||
self.display.show_wifi_indicator(connected=False, message=message)
|
||||
self._show_indicator_unless_dismissed(connected=False, message=message)
|
||||
except NetworkManagerError:
|
||||
self.display.show_wifi_indicator(connected=False, message="WiFi 状态不可用")
|
||||
self._show_indicator_unless_dismissed(connected=False, message="WiFi 状态不可用")
|
||||
|
||||
def _show_indicator_unless_dismissed(self, *, connected: bool, message: str) -> None:
|
||||
# NetworkManager reads happen outside this lock so opening a mobile
|
||||
# session never waits for them. Recheck at the actual submission point.
|
||||
with self._indicator_lock:
|
||||
if not self.store.session["dismissed"]:
|
||||
self.display.show_wifi_indicator(connected=connected, message=message)
|
||||
|
||||
@@ -3628,7 +3628,7 @@
|
||||
},
|
||||
"scope": "global",
|
||||
"source": "index.html::#topbar-mobile::text[0]",
|
||||
"text": "手机未连接"
|
||||
"text": "连接设备:未连接"
|
||||
},
|
||||
"copy.9a395662799ecd7a": {
|
||||
"kind": "html_text",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import threading
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
@@ -1160,6 +1161,13 @@ def test_wifi_settings_api_reveals_password_only_on_dedicated_get(tmp_path):
|
||||
"control_url": "http://192.168.9.93:8080/",
|
||||
},
|
||||
)
|
||||
startup_activation = threading.Event()
|
||||
activate = backend.activate
|
||||
def record_activation(*args, **kwargs):
|
||||
result = activate(*args, **kwargs)
|
||||
startup_activation.set()
|
||||
return result
|
||||
backend.activate = record_activation
|
||||
app = create_app(
|
||||
project_root=tmp_path,
|
||||
driver_kind="mock",
|
||||
@@ -1174,6 +1182,7 @@ def test_wifi_settings_api_reveals_password_only_on_dedicated_get(tmp_path):
|
||||
assert initial.json()["active"]["control_url"] == "http://192.168.9.93:8080/"
|
||||
status_network = client.get("/api/status").json()["network"]
|
||||
assert "password" not in status_network["saved"]
|
||||
assert startup_activation.wait(5), "boot network activation did not complete"
|
||||
startup_activation_count = len(backend.activations)
|
||||
|
||||
payload = {
|
||||
|
||||
@@ -4,9 +4,11 @@ from app.mobile.protocol import Handshake, ProtocolError, fragments
|
||||
from app.mobile.session import SessionManager
|
||||
|
||||
|
||||
def connection(clock=lambda: 0):
|
||||
def connection(clock=lambda: 0, *, manager=None, on_session_open=None):
|
||||
calls = []
|
||||
manager = SessionManager(lambda method, params: calls.append(method) or {}, lambda: {"device_id": "test"}, clock=clock)
|
||||
if manager is None:
|
||||
manager = SessionManager(lambda method, params: calls.append(method) or {}, lambda: {"device_id": "test"},
|
||||
clock=clock, on_session_open=on_session_open)
|
||||
assert manager.connect('a')
|
||||
assert not manager.connect('b')
|
||||
client = Handshake()
|
||||
@@ -69,6 +71,42 @@ def test_negotiated_receive_mtu_resets_on_disconnect():
|
||||
|
||||
@pytest.mark.parametrize('mtu', [0, 22, 518, True, '247'])
|
||||
def test_invalid_receive_mtu_never_opens_session(mtu):
|
||||
manager, cipher, _ = connection()
|
||||
opened = []
|
||||
manager, cipher, _ = connection(on_session_open=lambda: opened.append(True))
|
||||
assert not request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机', 'receive_mtu': mtu})['ok']
|
||||
assert not manager.opened
|
||||
assert opened == []
|
||||
|
||||
|
||||
def test_open_notification_only_after_valid_encrypted_session():
|
||||
opened = []
|
||||
manager, cipher, _ = connection(on_session_open=lambda: opened.append(manager.status()['connected']))
|
||||
assert opened == [] # Neither the link nor the handshake is a session.
|
||||
assert not request(manager, cipher, 1, '1', 'session.open', {'client_name': ''})['ok']
|
||||
assert opened == []
|
||||
assert request(manager, cipher, 2, '2', 'session.open', {'client_name': '手机'})['ok']
|
||||
assert opened == [True]
|
||||
assert not request(manager, cipher, 3, '3', 'session.open', {'client_name': '手机'})['ok']
|
||||
request(manager, cipher, 4, '4', 'session.ping')
|
||||
manager.disconnect('a')
|
||||
assert opened == [True]
|
||||
|
||||
|
||||
def test_failed_handshake_never_notifies():
|
||||
opened = []
|
||||
manager = SessionManager(lambda *args: {}, lambda: {}, on_session_open=lambda: opened.append(True))
|
||||
assert manager.connect('a')
|
||||
with pytest.raises(ProtocolError):
|
||||
for part in fragments(1, 0, b'invalid-handshake', 247):
|
||||
manager.accept('a', part)
|
||||
assert not manager.opened
|
||||
assert opened == []
|
||||
|
||||
|
||||
def test_notification_failure_does_not_break_open_session(caplog):
|
||||
def failed_notification():
|
||||
raise OSError('test notification failure')
|
||||
manager, cipher, _ = connection(on_session_open=failed_notification)
|
||||
assert request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机'})['ok']
|
||||
assert request(manager, cipher, 2, '2', 'session.ping')['ok']
|
||||
assert 'session-open notification failed' in caplog.text
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import json
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config.store import ConfigStore
|
||||
from app.display.service import DisplayService
|
||||
@@ -10,6 +13,8 @@ from app.display.wifi_indicator import render_wifi_indicator
|
||||
from app.network.manager import MockNetworkManager, NmcliNetworkManager
|
||||
from app.network.service import WifiNetworkService, validate_wifi_settings
|
||||
from app.network.store import WifiConfigError, WifiConfigStore
|
||||
from app.main import create_app
|
||||
from test_mobile_session import connection, request
|
||||
|
||||
|
||||
@pytest.mark.parametrize('raw_dns', ['192.0.2.1,192.0.2.2', '192.0.2.1\n192.0.2.2', '192.0.2.1, 192.0.2.2\n192.0.2.1'])
|
||||
@@ -287,9 +292,79 @@ def test_dismissed_network_prompt_does_not_reappear_after_disconnect(tmp_path):
|
||||
ipv4_address=None,
|
||||
control_url=None,
|
||||
)
|
||||
time.sleep(0.02)
|
||||
service._refresh_indicator()
|
||||
assert wifi_store.session["dismissed"] is True
|
||||
assert display.get_status()["state"]["wifi_indicator_active"] is False
|
||||
finally:
|
||||
service.close()
|
||||
display.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('prompt_state', ['before_delay', 'connected', 'disconnected'])
|
||||
def test_mobile_open_dismisses_prompt_and_preserves_user_frame(tmp_path, prompt_state):
|
||||
backend = MockNetworkManager(saved_profile(), active_profile())
|
||||
app = create_app(project_root=tmp_path, driver_kind='mock', startup_boot_id='boot-a', network_backend=backend)
|
||||
network = app.state.network_service
|
||||
display = app.state.display_service
|
||||
try:
|
||||
image = Image.new('RGB', (64, 64), (12, 34, 56))
|
||||
display.show_image(image, notify_activity=False)
|
||||
before = display.get_status()
|
||||
config_before = display.config_store.config
|
||||
assert not TestClient(app).get('/api/status').json()['network']['prompt']['dismissed_for_boot']
|
||||
if prompt_state != 'before_delay':
|
||||
backend.active = active_profile(connected=prompt_state == 'connected')
|
||||
network._refresh_indicator()
|
||||
assert display.get_status()['state']['wifi_indicator_active']
|
||||
manager, cipher, _ = connection(manager=app.state.mobile_sessions)
|
||||
assert not network.store.session['dismissed']
|
||||
assert request(manager, cipher, 1, '1', 'session.open', {'client_name': '手机'})['ok']
|
||||
manager.disconnect('a')
|
||||
for connected in [False, True, False]:
|
||||
backend.active = active_profile(connected=connected)
|
||||
network._refresh_indicator()
|
||||
assert not display.get_status()['state']['wifi_indicator_active']
|
||||
assert network.get_cached_status()['prompt']['dismissed_for_boot']
|
||||
assert display.get_current_frame().tobytes() == image.tobytes()
|
||||
after = display.get_status()
|
||||
for key in ['revision', 'brightness', 'orientation', 'mode', 'current_content']:
|
||||
assert after['state'][key] == before['state'][key]
|
||||
assert display.config_store.config == config_before
|
||||
same = WifiConfigStore(network.store.path.parent, network.store.session_path.parent, boot_id='boot-a')
|
||||
assert same.session['dismissed']
|
||||
next_boot = WifiConfigStore(network.store.path.parent, network.store.session_path.parent, boot_id='boot-b')
|
||||
assert not next_boot.session['dismissed']
|
||||
finally:
|
||||
network.close()
|
||||
display.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('connected', [False, True])
|
||||
def test_late_network_read_cannot_restore_dismissed_prompt(tmp_path, connected):
|
||||
config = ConfigStore(project_root=tmp_path)
|
||||
display = DisplayService(config, driver_kind='mock', startup_boot_id='boot')
|
||||
backend = MockNetworkManager(saved_profile(), active_profile(connected=connected))
|
||||
store = WifiConfigStore(config.data_dir, config.runtime_dir, boot_id='boot')
|
||||
service = WifiNetworkService(store, backend, display)
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
read_active = backend.read_active
|
||||
def delayed_read():
|
||||
entered.set()
|
||||
assert release.wait(5)
|
||||
return read_active()
|
||||
backend.read_active = delayed_read
|
||||
thread = threading.Thread(target=service._refresh_indicator)
|
||||
try:
|
||||
thread.start()
|
||||
assert entered.wait(5)
|
||||
service.dismiss_for_boot()
|
||||
release.set()
|
||||
thread.join(5)
|
||||
assert not thread.is_alive()
|
||||
assert store.session['dismissed']
|
||||
assert not display.get_status()['state']['wifi_indicator_active']
|
||||
finally:
|
||||
release.set()
|
||||
thread.join(5)
|
||||
service.close()
|
||||
display.close()
|
||||
|
||||
Reference in New Issue
Block a user