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

+88
View File
@@ -0,0 +1,88 @@
import { DialogButton, ModalRoot } from "@decky/ui";
import qrcode from "qrcode-generator";
import { ReactElement, useEffect, useState } from "react";
import { getState, startLogin, stopLogin, VpnState } from "./backend";
const POLL_MS = 2000;
function QrSvg({ text, size }: { text: string; size: number }) {
const qr = qrcode(0, "M");
qr.addData(text);
qr.make();
const count = qr.getModuleCount();
const quiet = 2;
const cells: ReactElement[] = [];
for (let r = 0; r < count; r++) {
for (let c = 0; c < count; c++) {
if (qr.isDark(r, c)) cells.push(<rect key={`${r}-${c}`} x={c + quiet} y={r + quiet} width={1} height={1} />);
}
}
const dim = count + quiet * 2;
return (
<svg width={size} height={size} viewBox={`0 0 ${dim} ${dim}`} shapeRendering="crispEdges">
<rect width={dim} height={dim} fill="#fff" />
<g fill="#000">{cells}</g>
</svg>
);
}
function remaining(expiresAt: number): string {
const s = Math.max(0, Math.round(expiresAt - Date.now() / 1000));
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
export function LoginModal({ closeModal }: { closeModal?: () => void }) {
const [state, setState] = useState<VpnState | null>(null);
const [, tick] = useState(0);
useEffect(() => {
let alive = true;
startLogin().then((s) => alive && setState(s));
const id = setInterval(async () => {
try {
const s = await getState();
if (!alive) return;
setState(s);
if (!s.need_login) closeModal?.(); // logged in: backend already closed the relay
} catch (e) {
console.error("mudfish: getState failed", e);
}
tick((n) => n + 1);
}, POLL_MS);
return () => {
alive = false;
clearInterval(id);
};
}, []);
const cancel = () => {
stopLogin();
closeModal?.();
};
const login = state?.login;
return (
<ModalRoot onCancel={cancel} closeModal={cancel}>
<div style={{ display: "flex", gap: "24px", alignItems: "center" }}>
<div style={{ flex: "0 0 auto" }}>
{login ? <QrSvg text={login.url} size={240} /> : <div style={{ width: 240, height: 240 }} />}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
<div style={{ fontSize: "20px", fontWeight: "bold" }}>휴대폰으로 미꾸라지 로그인</div>
{login ? (
<>
<div>1. 스팀덱과 같은 와이파이에 연결된 휴대폰으로 QR을 찍으세요.</div>
<div>2. 열린 미꾸라지 화면에서 <b>'로그인 기억하기'를 체크</b>하고 로그인하세요.</div>
<div>3. 로그인이 확인되면 이 창은 자동으로 닫혀요.</div>
<div style={{ opacity: 0.6, fontSize: "12px", wordBreak: "break-all" }}>{login.url}</div>
<div style={{ opacity: 0.8 }}>{`남은 시간 ${remaining(login.expires_at)} · 처음 QR을 찍은 휴대폰만 접속할 수 있어요`}</div>
</>
) : (
<div>{state?.message ?? "로그인 창을 여는 중…"}</div>
)}
<DialogButton onClick={cancel}>닫기</DialogButton>
</div>
</div>
</ModalRoot>
);
}
+9
View File
@@ -4,8 +4,10 @@ import {
Field,
PanelSection,
PanelSectionRow,
showModal,
ToggleField,
} from "@decky/ui";
import { LoginModal } from "./LoginModal";
import { useEffect, useRef, useState } from "react";
import {
connect,
@@ -158,6 +160,13 @@ export function Panel() {
<Field focusable={false} description={state.message} />
</PanelSectionRow>
)}
{state.need_login && (
<PanelSectionRow>
<ButtonItem layout="below" onClick={() => showModal(<LoginModal />)}>
휴대폰으로 로그인 (QR)
</ButtonItem>
</PanelSectionRow>
)}
<PanelSectionRow>
<ButtonItem layout="below" onClick={() => setAdvanced(!advanced)}>
{advanced ? "고급 ▾" : "고급 ▸"}
+4
View File
@@ -17,6 +17,8 @@ export interface VpnState {
country: string | null;
connected_since: number | null;
tunnel: boolean;
need_login: boolean;
login: { url: string; expires_at: number } | null;
}
export const getState = callable<[], VpnState>("get_state");
@@ -24,3 +26,5 @@ export const connect = callable<[], VpnState>("connect");
export const disconnect = callable<[], VpnState>("disconnect");
export const setNode = callable<[choice: string], VpnState>("set_node");
export const listNodes = callable<[], NodeInfo[]>("list_nodes");
export const startLogin = callable<[], VpnState>("start_login");
export const stopLogin = callable<[], VpnState>("stop_login");