feat: phone login via QR when the saved Mudfish login expires

When autologin is rejected the panel offers '휴대폰으로 로그인 (QR)'. It opens
a 5-minute LAN relay to the launcher UI guarded by a one-time token (first
phone to open the QR URL is the only client allowed) and shows the URL as a
QR code. The relay closes as soon as a new session appears, on cancel, on
expiry, and on unload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ysj7705andClaude Opus 5.5 committed 2026-10-24 21:42:12 +09:00
1 parent d8f1c05372
commit 7513fef6f8
12 files changed
+586 -7

No files matched your search

+105 -2
View File
@@ -3,8 +3,8 @@ import tempfile
import unittest
from mudfish_decky.client import AuthError, SigninError
from mudfish_decky.controller import (MSG_MISSING, MSG_NO_RESPONSE, MSG_RELOGIN, MSG_RTT, MSG_SLEEP, MSG_STILL_UP,
MSG_TIMEOUT, Controller)
from mudfish_decky.controller import (MSG_LOGGED_IN, MSG_MISSING, MSG_NO_LAN, MSG_NO_RESPONSE, MSG_RELOGIN, MSG_RTT,
MSG_SLEEP, MSG_STILL_UP, MSG_TIMEOUT, Controller)
from mudfish_decky.store import Store
from tests.test_nodes import STATIC
@@ -20,6 +20,10 @@ class FakeSystem:
self.deleted = []
self.daemon_calls = 0
self.ip = {"ip": "13.73.234.162", "country": "JP"}
self.lan = "192.168.1.50"
def lan_ip(self):
return self.lan
def find_mudfish(self):
return ("/opt/mudfish/6.6.0/bin/mudrun-headless", "/opt/mudfish/6.6.0/var") if self.installed else None
@@ -41,6 +45,25 @@ class FakeSystem:
return self.ip
class FakeRelay:
token = "tok"
expires_at = 1300.0
def __init__(self):
self.active = False
self.started = False
self.start_calls = 0
def start(self, host="0.0.0.0"):
self.started = True
self.active = True
self.start_calls += 1
return 40123
def stop(self):
self.active = False
class FakeClient:
def __init__(self, system):
self.system = system
@@ -333,6 +356,86 @@ class ControllerTest(unittest.TestCase):
self.assertEqual(st["message"], MSG_RTT)
self.assertEqual(self.client.start_calls, 0)
# ---- phone (QR) login --------------------------------------------------------------------
def make_with_relay(self):
self.relay = FakeRelay()
return Controller(self.client, self.system, Store(self.path), clock=lambda: self.now,
sleep=lambda s: None, relay_factory=lambda: self.relay)
def test_connect_signin_failure_flags_need_login(self):
self.client.signin_error = SigninError("401")
self.assertTrue(self.make().connect()["need_login"])
def test_prepare_signin_failure_flags_need_login(self):
self.client.signin_error = SigninError("401")
c = self.make()
c.prepare()
st = c.state()
self.assertTrue(st["need_login"])
self.assertEqual(st["message"], MSG_RELOGIN)
def test_prepare_network_failure_does_not_flag_need_login(self):
self.client.signin_error = OSError("name resolution")
c = self.make()
c.prepare()
self.assertFalse(c.state()["need_login"])
def test_need_login_false_by_default(self):
self.assertFalse(self.make().state()["need_login"])
def test_start_login_exposes_qr_url(self):
c = self.make_with_relay()
st = c.start_login()
self.assertEqual(st["login"], {"url": "http://192.168.1.50:40123/_mfd/tok", "expires_at": 1300.0})
self.assertTrue(self.relay.started)
self.assertEqual(self.client.start_calls, 0)
def test_start_login_reuses_active_relay(self):
c = self.make_with_relay()
c.start_login()
c.start_login()
self.assertEqual(self.relay.start_calls, 1)
def test_start_login_without_lan_ip_shows_message(self):
self.system.lan = None
st = self.make_with_relay().start_login()
self.assertIsNone(st["login"])
self.assertEqual(st["message"], MSG_NO_LAN)
self.assertFalse(self.relay.started)
def test_state_closes_relay_after_signin(self):
self.client.signin_error = SigninError("401")
self.client.st["isSignedIn"] = False
c = self.make_with_relay()
c.connect()
c.start_login()
self.client.st["isSignedIn"] = True # user logged in from the phone
st = c.state()
self.assertFalse(self.relay.active)
self.assertIsNone(st["login"])
self.assertFalse(st["need_login"])
self.assertEqual(st["phase"], "off")
self.assertEqual(st["message"], MSG_LOGGED_IN)
def test_stop_login_closes_relay(self):
c = self.make_with_relay()
c.start_login()
self.assertIsNone(c.stop_login()["login"])
self.assertFalse(self.relay.active)
def test_expired_relay_drops_from_state(self):
c = self.make_with_relay()
c.start_login()
self.relay.active = False
self.assertIsNone(c.state()["login"])
def test_shutdown_closes_relay(self):
c = self.make_with_relay()
c.start_login()
c.shutdown()
self.assertFalse(self.relay.active)
# ---- sleep / shutdown --------------------------------------------------------------------
def test_resume_jump_turns_tunnel_off_with_notice(self):
+111
View File
@@ -0,0 +1,111 @@
import socket
import threading
import unittest
from mudfish_decky.relay import Gate, LoginRelay, parse_request_path
def http_get(port, path):
with socket.create_connection(("127.0.0.1", port), timeout=5) as s:
s.sendall(f"GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n".encode())
chunks = []
while True:
data = s.recv(4096)
if not data:
break
chunks.append(data)
return b"".join(chunks).decode()
class FakeTarget:
"""Minimal HTTP server standing in for the Mudfish launcher."""
def __init__(self):
self.srv = socket.create_server(("127.0.0.1", 0))
self.port = self.srv.getsockname()[1]
threading.Thread(target=self._serve, daemon=True).start()
def _serve(self):
while True:
try:
conn, _ = self.srv.accept()
except OSError:
return
with conn:
conn.recv(4096)
conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello")
def close(self):
self.srv.close()
class GateTest(unittest.TestCase):
def test_denies_until_token_seen(self):
g = Gate("tok")
self.assertEqual(g.decide("10.0.0.2", "/"), "deny")
self.assertEqual(g.decide("10.0.0.2", "/_mfd/wrong"), "deny")
def test_first_token_holder_is_the_only_client(self):
g = Gate("tok")
self.assertEqual(g.decide("10.0.0.2", "/_mfd/tok"), "authorize")
self.assertEqual(g.decide("10.0.0.2", "/do/mudrun/status"), "pipe")
self.assertEqual(g.decide("10.0.0.3", "/_mfd/tok"), "deny")
self.assertEqual(g.decide("10.0.0.3", "/"), "deny")
def test_query_string_on_token_path(self):
self.assertEqual(Gate("tok").decide("10.0.0.2", "/_mfd/tok?x=1"), "authorize")
class ParseTest(unittest.TestCase):
def test_parse_request_path(self):
self.assertEqual(parse_request_path(b"GET /_mfd/tok HTTP/1.1\r\nHost: x\r\n\r\n"), "/_mfd/tok")
def test_parse_partial_or_garbage(self):
self.assertIsNone(parse_request_path(b"GET /incomplete"))
self.assertIsNone(parse_request_path(b"\x16\x03\x01binary"))
class LoginRelayTest(unittest.TestCase):
def setUp(self):
self.target = FakeTarget()
self.relay = LoginRelay(target=("127.0.0.1", self.target.port), ttl=60)
self.port = self.relay.start(host="127.0.0.1")
def tearDown(self):
self.relay.stop()
self.target.close()
def test_token_is_random_and_url_safe(self):
other = LoginRelay(target=("127.0.0.1", 1))
self.assertNotEqual(self.relay.token, other.token)
self.assertGreaterEqual(len(self.relay.token), 16)
self.assertRegex(self.relay.token, r"^[A-Za-z0-9_-]+$")
def test_forbidden_before_token(self):
self.assertIn("403", http_get(self.port, "/").splitlines()[0])
def test_token_redirects_then_proxies(self):
first = http_get(self.port, f"/_mfd/{self.relay.token}")
self.assertIn("302", first.splitlines()[0])
self.assertIn("Location: /", first)
self.assertTrue(http_get(self.port, "/").endswith("hello"))
def test_stop_closes_port(self):
self.relay.stop()
self.assertFalse(self.relay.active)
with self.assertRaises(OSError):
socket.create_connection(("127.0.0.1", self.port), timeout=2).close()
def test_expires_after_ttl(self):
now = [1000.0]
relay = LoginRelay(target=("127.0.0.1", self.target.port), ttl=300, clock=lambda: now[0])
relay.start(host="127.0.0.1")
self.assertEqual(relay.expires_at, 1300.0)
self.assertTrue(relay.active)
now[0] = 1301.0
self.assertFalse(relay.active)
relay.stop()
if __name__ == "__main__":
unittest.main()
+10
View File
@@ -35,6 +35,16 @@ class SystemTest(unittest.TestCase):
with tempfile.TemporaryDirectory() as var:
self.assertEqual(api_base(var), "http://127.0.0.1:8282")
def test_lan_ip_from_route_get(self):
from mudfish_decky.system import lan_ip
out = "1.1.1.1 via 192.168.1.1 dev wlan0 src 192.168.1.50 uid 0 \n cache \n"
self.assertEqual(lan_ip(run=lambda cmd, **kw: types.SimpleNamespace(stdout=out, returncode=0)),
"192.168.1.50")
def test_lan_ip_none_without_route(self):
from mudfish_decky.system import lan_ip
self.assertIsNone(lan_ip(run=lambda cmd, **kw: types.SimpleNamespace(stdout="", returncode=2)))
def test_sleep_offset_is_boottime_minus_monotonic(self):
from mudfish_decky.system import sleep_offset
clocks = {"boot": 500.0, "mono": 420.0}