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:
1 parent
d8f1c05372
commit
7513fef6f8
12 files changed
+586
-7
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ? "고급 ▾" : "고급 ▸"}
|
||||
|
||||
@@ -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");
|
||||
Reference in new issue
Block a user