feat: quick access panel for Mudfish Full VPN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
384d0a75f0
commit
cf7fc1aa1e
3 files changed
+1988
-2
No files matched your search
+163
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
ButtonItem,
|
||||
DropdownItem,
|
||||
Field,
|
||||
PanelSection,
|
||||
PanelSectionRow,
|
||||
ToggleField,
|
||||
} from "@decky/ui";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
connect,
|
||||
disconnect,
|
||||
getState,
|
||||
listNodes,
|
||||
NodeInfo,
|
||||
Phase,
|
||||
setNode,
|
||||
VpnState,
|
||||
} from "./backend";
|
||||
|
||||
const PHASE_TEXT: Record<Phase, string> = {
|
||||
off: "꺼짐",
|
||||
connecting: "연결 중…",
|
||||
on: "연결됨",
|
||||
error: "오류",
|
||||
missing: "미꾸라지 미설치",
|
||||
};
|
||||
|
||||
const STATE_POLL_MS = 2000;
|
||||
const NODES_POLL_MS = 10000;
|
||||
|
||||
function elapsed(since: number, nowMs: number): string {
|
||||
const minutes = Math.floor((nowMs / 1000 - since) / 60);
|
||||
if (minutes < 1) return "방금";
|
||||
if (minutes < 60) return `${minutes}분째`;
|
||||
return `${Math.floor(minutes / 60)}시간 ${minutes % 60}분째`;
|
||||
}
|
||||
|
||||
function rttText(rtt: number | null): string {
|
||||
return rtt === null ? "측정 안 됨" : `${Math.round(rtt)}ms`;
|
||||
}
|
||||
|
||||
function nodeLine(state: VpnState): string {
|
||||
const node = state.node;
|
||||
if (!node) return "핑 측정 중…";
|
||||
const body = node.rtt === null ? node.label : `${node.label} (${Math.round(node.rtt)}ms)`;
|
||||
return state.node_mode === "auto" ? `자동 · ${body}` : body;
|
||||
}
|
||||
|
||||
function StatusValue({ state }: { state: VpnState }) {
|
||||
if (state.phase !== "on") return <span>{PHASE_TEXT[state.phase]}</span>;
|
||||
const jp = state.country === "JP";
|
||||
const flag = jp ? "🇯🇵 JP" : state.country ?? "?";
|
||||
const since = state.connected_since ? ` · ${elapsed(state.connected_since, Date.now())}` : "";
|
||||
return (
|
||||
<span style={{ color: jp ? undefined : "#ff6b6b" }}>
|
||||
{`${PHASE_TEXT.on} · ${flag}${state.ip ? ` · ${state.ip}` : ""}${since}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Panel() {
|
||||
const [state, setState] = useState<VpnState | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [advanced, setAdvanced] = useState(false);
|
||||
const [nodes, setNodes] = useState<NodeInfo[]>([]);
|
||||
const polling = useRef(false);
|
||||
|
||||
const refresh = async () => {
|
||||
if (polling.current) return;
|
||||
polling.current = true;
|
||||
try {
|
||||
setState(await getState());
|
||||
} finally {
|
||||
polling.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
const id = setInterval(refresh, STATE_POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!advanced) return;
|
||||
const load = async () => setNodes(await listNodes());
|
||||
load();
|
||||
const id = setInterval(load, NODES_POLL_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [advanced]);
|
||||
|
||||
const run = async (action: () => Promise<VpnState>, pending?: Phase) => {
|
||||
setBusy(true);
|
||||
if (pending && state) setState({ ...state, phase: pending, message: null });
|
||||
try {
|
||||
setState(await action());
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!state) {
|
||||
return (
|
||||
<PanelSection>
|
||||
<PanelSectionRow>
|
||||
<Field label="상태">불러오는 중…</Field>
|
||||
</PanelSectionRow>
|
||||
</PanelSection>
|
||||
);
|
||||
}
|
||||
|
||||
const active = state.phase === "on" || state.phase === "connecting";
|
||||
const options = [
|
||||
{ data: "auto", label: "자동 (핑 최저)" },
|
||||
...nodes.map((n) => ({ data: String(n.sid), label: `${n.label} · ${rttText(n.rtt)}` })),
|
||||
];
|
||||
const selected = state.node_mode === "auto" ? "auto" : String(state.node?.sid ?? "auto");
|
||||
|
||||
return (
|
||||
<PanelSection>
|
||||
<PanelSectionRow>
|
||||
<Field label="상태" focusable={false}>
|
||||
<StatusValue state={state} />
|
||||
</Field>
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
<ToggleField
|
||||
label="미꾸라지 Full VPN"
|
||||
checked={active}
|
||||
disabled={busy || state.phase === "connecting" || state.phase === "missing"}
|
||||
onChange={(on) => run(on ? connect : disconnect, on ? "connecting" : undefined)}
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
<PanelSectionRow>
|
||||
<Field label="노드" focusable={false}>
|
||||
{nodeLine(state)}
|
||||
</Field>
|
||||
</PanelSectionRow>
|
||||
{state.message && (
|
||||
<PanelSectionRow>
|
||||
<Field focusable={false} description={state.message} />
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={() => setAdvanced(!advanced)}>
|
||||
{advanced ? "고급 ▾" : "고급 ▸"}
|
||||
</ButtonItem>
|
||||
</PanelSectionRow>
|
||||
{advanced && (
|
||||
<PanelSectionRow>
|
||||
<DropdownItem
|
||||
label="일본 노드"
|
||||
rgOptions={options}
|
||||
selectedOption={selected}
|
||||
disabled={busy || state.phase === "connecting"}
|
||||
onChange={(opt) => run(() => setNode(String(opt.data)))}
|
||||
/>
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
</PanelSection>
|
||||
);
|
||||
}
|
||||
+3
-2
@@ -1,10 +1,11 @@
|
||||
import { PanelSection, staticClasses } from "@decky/ui";
|
||||
import { staticClasses } from "@decky/ui";
|
||||
import { definePlugin } from "@decky/api";
|
||||
import { FaFish } from "react-icons/fa";
|
||||
import { Panel } from "./Panel";
|
||||
|
||||
export default definePlugin(() => ({
|
||||
name: "Mudfish VPN",
|
||||
titleView: <div className={staticClasses.Title}>Mudfish VPN</div>,
|
||||
content: <PanelSection />,
|
||||
content: <Panel />,
|
||||
icon: <FaFish />,
|
||||
}));
|
||||
Reference in new issue
Block a user