31 KiB
미꾸라지 Full VPN 데키 플러그인 구현 계획
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 게임모드 데키 패널에서 미꾸라지 Full VPN을 켜고 끄고, 일본 노드(기본 자동=핑 최저)를 고르는 플러그인을 만든다.
Architecture: Python 백엔드(py_modules/mudfish_decky/)가 미꾸라지 로컬 API(127.0.0.1:8282)와 클라우드 API(api.mudfish.net)를 호출해 데몬 상주, 자동 로그인, Full VPN 노드 지정, 연결/해제, 경로 정리를 맡는다. main.py는 이를 데키 Plugin 메서드로 노출한다. React 프론트엔드(src/)는 상태를 폴링해 그리고 사용자 조작을 전달하기만 한다. 순수 로직(노드 정렬, 경로 비교, 상태 판정)은 I/O와 분리해 단위 테스트한다.
Tech Stack: Decky Loader v3.3.0 플러그인 API v1 (@decky/ui, @decky/api, @decky/rollup, import decky), TypeScript/React, Python 3.11 호환 표준 라이브러리만, 테스트는 스팀덱의 python3 -m unittest.
Spec: docs/superpowers/specs/2026-10-24-mudfish-decky-design.md
Global Constraints
- 스팀덱:
deck@192.168.1.50(SSH 키 인증). 개발 중 sudo 비밀번호 없음 설정이 켜져 있다. 플러그인 코드는 sudo를 쓰지 않는다(백엔드는 root로 실행). plugin.json:"name": "Mudfish VPN","flags": ["root"],"api_version": 1. 설치 폴더는~/homebrew/plugins/mudfish-decky/.- Python은 3.11 문법까지만 쓴다(데키 내장 파이썬 기준). 서드파티 패키지는 쓰지 않는다.
py_modules/에는 우리 코드만 둔다. - 미꾸라지 바이너리:
/opt/mudfish/<버전>/bin/mudrun-headless중 가장 높은 버전. 실행:systemd-run --unit=mudfish-decky --collect <bin> -B. - 로컬 API 기본 주소는
http://127.0.0.1:8282. 실제 포트는/opt/mudfish/<버전>/var/.ipport(127.0.0.1:8282형식). - 클라우드 API 호스트는
status.api_server(현재api.mudfish.net), 인증 헤더는Authorization: <jwt>(Bearer 없음). - 일본 노드:
location이"JP "로 시작하는 staticNode. 표시 라벨은 괄호 안 문자열(예:Tokyo - Linode3 IDC). - 코어(
/do/mudfish/start)는 사용자가 켜기를 눌렀을 때만 시작한다._main, 절전 복귀, 상태 조회, 노드 선택 어떤 경로에서도 시작하지 않는다(노드 변경은 이미 연결된 상태에서만 재적용). - 끄기를 눌러도 계정의
mudfishFullvpnSid는 지우지 않는다. - 사용자에게 보이는 문구는 한국어다. 문구는 Task 6의 표에 있는 그대로 쓴다.
- 플러그인 설정 파일:
<DECKY_PLUGIN_SETTINGS_DIR>/settings.json.
Review Focus
- 플러그인을 다시 불러오거나 재부팅했는데 VPN이 연결된 상태: 자동으로 끄거나 켜지 않는다. 패널은
on과, 저장해 둔 연결 시각 기준의 경과 시간을 보여 준다. → Task 4test_state_on_survives_restart_uses_saved_since - 부팅 직후 핑 측정 전에 자동 모드로 켜기: 노드를 고를 수 없으면 코어를 시작하지 않고 안내 문구를 띄운다. → Task 4
test_connect_auto_without_rtts_does_not_start - 자동 로그인 토큰 만료(signin이 200이 아님, 401 포함): 코어를 시작하지 않고 "데스크탑 모드에서 다시 로그인" 문구를 띄운다. → Task 4
test_connect_signin_failure_shows_relogin - 연결 시간 초과(client_ready가 끝내 안 옴): 반쯤 켜진 상태로 남기지 않도록 stop을 호출하고
error문구를 띄운다. → Task 4test_connect_timeout_stops_core - 와이파이를 바꾼 뒤 끄기, 또는 시작 전 경로 스냅샷이 없을 때: 스냅샷이 있으면 그 이후에 추가된 호스트 경로만 지우고, 없으면 아무것도 지우지 않는다. → Task 3
test_leftover_routes_*
File Structure
mudfish-decky/
├─ plugin.json, package.json, rollup.config.js, tsconfig.json, .gitignore
├─ main.py # Decky Plugin 클래스: 비동기 래퍼만
├─ py_modules/mudfish_decky/
│ ├─ __init__.py
│ ├─ nodes.py # 일본 노드 필터·라벨·핑 정렬·자동 선택 (순수)
│ ├─ routes.py # `ip -4 route` 파싱·남은 경로 계산 (순수) + 삭제 실행
│ ├─ client.py # 로컬/클라우드 HTTP 호출 (주입 가능한 transport)
│ ├─ system.py # 바이너리 탐색, 데몬 기동, tap0 확인, 공인 IP 조회
│ ├─ store.py # settings.json 읽기/쓰기
│ └─ controller.py # 연결/해제/상태/노드 선택 오케스트레이션
├─ tests/ # unittest, 스팀덱에서 실행
│ ├─ test_nodes.py, test_routes.py, test_client.py, test_controller.py
├─ src/
│ ├─ index.tsx # definePlugin
│ ├─ Panel.tsx # 패널 UI
│ └─ backend.ts # callable 정의와 타입
└─ scripts/
├─ test.sh # py_modules+tests를 덱에 복사해 unittest 실행
└─ deploy.sh # 빌드 산출물을 덱에 설치하고 plugin_loader 재시작
Task 1: Full VPN 실측 (스파이크, 코드 없음)
스펙 9절의 검증 항목 13, 56을 덱에서 직접 확인한다. Task 4의 연결 절차가 이 결과에 의존한다. 미꾸라지 데몬은 현재 mudfish-dev unit으로 떠 있고 로그인된 상태다(로그인이 풀렸으면 스펙 4절 자동 로그인 절차를 덱에서 python3로 다시 실행).
Files:
-
Modify:
docs/superpowers/specs/2026-10-24-mudfish-decky-design.md(9절 아래에 "실측 결과" 하위 절 추가) -
Step 1: 연결 전 기준값 기록
Run (덱): ip -4 route; resolvectl status | head -20; curl -s -m 8 https://ipinfo.io/json
Expected: 기본 경로 via 192.168.1.1 dev wlan0, 국가 KR.
- Step 2: sid를 핑 최저 일본 노드로 저장
덱에서 python3 스크립트로 실행한다. graphql로 progConf 전체 필드(스펙 4절 목록)를 읽고 mudfishFullvpnSid만 바꿔 POST https://api.mudfish.net/prog로 보낸다. 노드는 /do/rtt/staticnodes의 rttavg 최저 일본 sid로 한다. 응답의 status, msg, need_restart를 기록한다.
Expected: status == 200, 이후 GET /do/mudrun/status의 mudfish_fullvpn_sid가 그 sid로 바뀐다(바뀌지 않으면 데몬 재로그인이 필요한지 기록).
- Step 3: 연결 후 확인
Run (덱): curl -s -X POST http://127.0.0.1:8282/do/mudfish/start를 실행하고 client_ready가 될 때까지 기다린 뒤, ip -4 route; ip -br link; resolvectl status | head -20; curl -s -m 8 https://ipinfo.io/json; getent ahostsv4 login.nvidia.com; ip route get $(getent ahostsv4 login.nvidia.com | awk 'NR==1{print $1}'); tailscale status | head -3
Expected: 공인 IP 국가 JP, login.nvidia.com 경로가 dev tap0. 기본 경로를 바꾸는 방식(0/1+128/1 분할인지 등), DNS 변화, Tailscale 상태를 그대로 기록한다.
- Step 4: 연결 중 sid 변경
다른 일본 sid로 Step 2를 반복하고 need_restart를 기록한다. need_restart가 false면 POST /do/fullvpn/refresh(form: sid, exceptions=progConf의 mudfishFullvpnExceptions)를 호출한 뒤 공인 IP가 바뀌는지 확인한다.
Expected: 어느 방법으로 새 노드가 적용되는지 기록한다.
- Step 5: 해제 후 확인
Run (덱): curl -s -X POST http://127.0.0.1:8282/do/mudfish/stop를 실행하고 6초 기다린 뒤 Step 3의 명령을 다시 실행한다.
Expected: 국가 KR, tap0 없음, 기본 경로가 Step 1과 같다. Step 1 대비 남은 경로를 기록한다.
- Step 6: 게임모드 전환 시 데몬 유지 확인
사용자에게 게임모드로 전환했다가 돌아와 달라고 요청한 뒤, systemctl is-active mudfish-dev를 실행한다.
Expected: active.
- Step 7: 결과를 스펙에 기록하고, Task 4 절차와 다르면 사용자에게 보고
Step 2~6의 결과를 스펙 9절 아래 "실측 결과"로 적는다. 다음 경우에는 Task 4로 넘어가기 전에 사용자에게 알린다: Full VPN이 일본 IP를 만들지 못함, need_restart 처리가 Task 4의 가정(아래 apply_node)과 다름, Tailscale이 끊김.
Task 2: 프로젝트 골격과 덱 테스트 러너
Files:
- Create:
.gitignore,plugin.json,package.json,rollup.config.js,tsconfig.json,main.py,py_modules/mudfish_decky/__init__.py,src/index.tsx,scripts/test.sh,scripts/deploy.sh,tests/test_smoke.py
Interfaces:
-
Produces:
scripts/test.sh(인자 없음, 덱에서python3 -m unittest discover -s tests -t .결과를 그대로 출력하고 종료코드 전달),scripts/deploy.sh(인자 없음,pnpm build후 덱에 설치하고plugin_loader재시작) -
Step 1: git 저장소와 도구 준비
Run: git init, 그리고 Node가 없으므로 Chocolatey로 choco install nodejs-lts -y(관리자 권한이 필요하면 사용자에게 관리자 PowerShell에서 실행을 요청) 후 새 셸에서 npm i -g pnpm@9
Expected: node -v가 v20 이상, pnpm -v가 9.x.
- Step 2: 템플릿 설정 파일 가져오기
SteamDeckHomebrew/decky-plugin-template(main 브랜치)의 package.json, rollup.config.js, tsconfig.json, .gitignore를 그대로 가져와 package.json의 name을 mudfish-decky로 바꾼다. plugin.json은 Global Constraints 값으로 쓴다. src/index.tsx는 definePlugin으로 제목 "Mudfish VPN"과 빈 PanelSection 하나만 그린다. main.py는 import decky 후 빈 Plugin 클래스(_main, _unload는 decky.logger.info만)다. main.py 맨 위에서 sys.path에 os.path.join(os.path.dirname(__file__), "py_modules")를 추가한다(데키가 이미 추가해도 무해).
- Step 3: 스모크 테스트와 test.sh 작성
tests/test_smoke.py: import mudfish_decky가 되는지 확인한다(self.assertTrue(hasattr(mudfish_decky, "__name__"))).
scripts/test.sh: tar -cf - py_modules tests | ssh deck@192.168.1.50 'rm -rf /tmp/mfd-test && mkdir /tmp/mfd-test && tar -xf - -C /tmp/mfd-test && cd /tmp/mfd-test && PYTHONPATH=py_modules python3 -m unittest discover -s tests -t . -v'
- Step 4: 테스트 실행
Run: bash scripts/test.sh
Expected: Ran 1 test ... OK
- Step 5: deploy.sh 작성과 첫 배포
scripts/deploy.sh: pnpm build(실패하면 중단) → tar -cf - dist main.py plugin.json package.json py_modules | ssh deck@192.168.1.50 'D=/home/deck/homebrew/plugins/mudfish-decky; sudo rm -rf "$D" && sudo mkdir -p "$D" && sudo tar -xf - -C "$D" && sudo systemctl restart plugin_loader'
Run: pnpm i && bash scripts/deploy.sh, 그리고 ssh deck@192.168.1.50 'journalctl -u plugin_loader --since "-1min" | grep -i mudfish'
Expected: 로그에 플러그인 로드 메시지가 있고 오류가 없다. 사용자에게 게임모드 데키 목록에 "Mudfish VPN"이 보이는지 확인을 요청한다.
- Step 6: Commit
git add -A
git commit -m "chore: scaffold Mudfish VPN decky plugin"
Task 3: 순수 로직 — 노드와 경로
Files:
- Create:
py_modules/mudfish_decky/nodes.py,py_modules/mudfish_decky/routes.py - Test:
tests/test_nodes.py,tests/test_routes.py
Interfaces:
-
Produces (
nodes.py):@dataclass(frozen=True) class Node: sid: int; label: str; rtt: float | Nonejapan_nodes(static_nodes: list[dict], rtts: dict[int, float]) -> list[Node]— 핑 오름차순, 핑 없는 노드는 라벨순으로 뒤에.pick_auto(nodes: list[Node]) -> Node | None— 핑이 있는 첫 노드, 없으면 None.rtts_from_response(resp: dict) -> dict[int, float]—rtts중success > 0인 항목의sid → rttavg.
-
Produces (
routes.py):host_routes(route_text: str) -> set[str]—ip -4 route출력에서/가 없는 목적지이고proto가 없으며via가 있는 줄을 공백 정규화해서 모은다.leftover_routes(before: set[str] | None, after_text: str) -> list[str]— before가 None이면[]. 아니면host_routes(after) - before중dev tap아닌 줄, 정렬해서.delete_routes(lines: list[str], run=subprocess.run) -> int— 각 줄을["ip", "route", "del", *line.split()]로 실행하고 성공한 개수를 돌려준다.
-
Step 1: 실패하는 테스트 작성
tests/test_nodes.py:
STATIC = [
{"sid": 9, "location": "JP Asia (Tokyo - Linode3 IDC)"},
{"sid": 11, "location": "JP Asia (Osaka - SakuraNet 4)"},
{"sid": 46, "location": "JP Asia (Hokkaido - SakuraNet 6)"},
{"sid": 300, "location": "US West (Los Angeles - HostUS)"},
]
RESP = {"status": 200, "rtts": [
{"sid": 9, "rttavg": 38.5, "success": 4, "failure": 0},
{"sid": 11, "rttavg": 31.0, "success": 4, "failure": 0},
{"sid": 46, "rttavg": 0.0, "success": 0, "failure": 4},
{"sid": 300, "rttavg": 5.0, "success": 4, "failure": 0},
]}
def test_rtts_skip_failed(self):
self.assertEqual(rtts_from_response(RESP), {9: 38.5, 11: 31.0, 300: 5.0})
def test_japan_only_sorted_by_rtt_unmeasured_last(self):
nodes = japan_nodes(STATIC, rtts_from_response(RESP))
self.assertEqual([n.sid for n in nodes], [11, 9, 46])
self.assertEqual(nodes[0].label, "Osaka - SakuraNet 4")
self.assertIsNone(nodes[2].rtt)
def test_pick_auto_lowest(self):
self.assertEqual(pick_auto(japan_nodes(STATIC, rtts_from_response(RESP))).sid, 11)
def test_pick_auto_none_without_rtts(self):
self.assertIsNone(pick_auto(japan_nodes(STATIC, {})))
tests/test_routes.py (덱에서 관찰한 실제 형식):
BEFORE = """default via 192.168.1.1 dev wlan0 proto dhcp src 192.168.1.50 metric 600
192.168.1.0/24 dev wlan0 proto kernel scope link src 192.168.1.50 metric 600
"""
AFTER = BEFORE + """1.201.174.247 via 192.168.1.1 dev wlan0
2.58.243.110 via 192.168.1.1 dev wlan0
10.254.100.5 via 10.254.100.1 dev tap0
"""
def test_host_routes_excludes_default_prefix_and_proto(self):
self.assertEqual(host_routes(BEFORE), set())
def test_leftover_routes_only_new_non_tap(self):
self.assertEqual(leftover_routes(host_routes(BEFORE), AFTER),
["1.201.174.247 via 192.168.1.1 dev wlan0", "2.58.243.110 via 192.168.1.1 dev wlan0"])
def test_leftover_routes_none_snapshot_deletes_nothing(self):
self.assertEqual(leftover_routes(None, AFTER), [])
def test_leftover_routes_keeps_preexisting_host_routes(self):
before = host_routes(AFTER.replace("2.58.243.110", "9.9.9.9"))
self.assertNotIn("1.201.174.247 via 192.168.1.1 dev wlan0", leftover_routes(before, AFTER))
def test_delete_routes_runs_ip_route_del(self):
calls = []
def run(cmd, **kw):
calls.append(cmd); return types.SimpleNamespace(returncode=0)
self.assertEqual(delete_routes(["1.2.3.4 via 192.168.1.1 dev wlan0"], run=run), 1)
self.assertEqual(calls, [["ip", "route", "del", "1.2.3.4", "via", "192.168.1.1", "dev", "wlan0"]])
- Step 2: 실패 확인
Run: bash scripts/test.sh
Expected: ImportError/ModuleNotFoundError로 FAIL
- Step 3:
nodes.py와routes.py를 위 Interfaces대로 구현
라벨은 location에서 첫 ( 다음부터 마지막 ) 전까지다. delete_routes는 run(cmd, capture_output=True, timeout=5)으로 부르고, 예외는 삼켜서 실패로 센다.
- Step 4: 통과 확인
Run: bash scripts/test.sh
Expected: 전부 OK
- Step 5: Commit
git add py_modules tests
git commit -m "feat: japan node ranking and leftover route detection"
Task 4: 미꾸라지 클라이언트, 시스템 연동, 컨트롤러
Files:
- Create:
py_modules/mudfish_decky/client.py,py_modules/mudfish_decky/system.py,py_modules/mudfish_decky/store.py,py_modules/mudfish_decky/controller.py - Test:
tests/test_client.py,tests/test_controller.py
Interfaces:
- Consumes: Task 3의
Node,japan_nodes,pick_auto,rtts_from_response,host_routes,leftover_routes,delete_routes - Produces (
client.py):class MudfishError(Exception),class SigninError(MudfishError)Transport = Callable[[str, str, bytes | None, dict[str, str]], tuple[int, bytes]](method, url, body, headers) → (http status, body). 기본 구현은urllib.request이고 timeout은 10초다.class MudfishClient(base_url: str, transport: Transport = urllib_transport), 메서드:status() -> dict,ensure_signed_in() -> dict(스펙 4절 자동 로그인 절차. 이미isSignedIn이면 그냥 status 반환. signin 응답이 200이 아니면SigninError),start() -> None(응답 status 200/201이 아니면MudfishError),stop() -> None,rtts() -> dict[int, float],static_nodes(jwt, api) -> list[dict],prog_conf(jwt, api) -> dict,save_prog_conf(jwt, api, conf: dict) -> dict(응답 그대로; status가 200이 아니면MudfishError),fullvpn_refresh(sid: int, exceptions: str) -> None
- Produces (
system.py):find_mudfish(root="/opt/mudfish") -> tuple[str, str] | None— (bin 경로, var 디렉터리), 버전은.기준 정수 튜플로 비교api_base(var_dir: str) -> str—.ipport내용으로http://<ipport>를 만들고, 없으면http://127.0.0.1:8282ensure_daemon(bin_path: str, is_up: Callable[[], bool], run=subprocess.run) -> None—is_up()이면 아무것도 안 하고, 아니면systemd-run --unit=mudfish-decky --collect <bin> -B를 실행한 뒤 최대 10초 동안is_up()을 기다린다tap_up() -> bool—/sys/class/net/tap0존재 여부route_text(run=subprocess.run) -> str—ip -4 route출력public_ip() -> dict | None—https://ipinfo.io/json에서{"ip", "country"}, 실패하면 None
- Produces (
store.py):class Store(path),load() -> dict,save(d: dict). 키는node_mode("auto"|"manual", 기본 "auto"),manual_sid(int|None),connected_since(float|None, epoch),routes_before(list[str]|None)이다. - Produces (
controller.py):class Controller(client, system, store, clock=time.time, sleep=time.sleep)—system은system.py모듈과 같은 이름의 함수를 가진 객체다(테스트에서 가짜 주입).ensure_daemon의is_up은client.status()가 예외 없이 끝나는지로 판단한다.- 문구 상수(정확히 이 값, Task 6 UI도 사용):
MSG_RELOGIN = "데스크탑 모드에서 미꾸라지(127.0.0.1:8282)에 '로그인 기억하기'로 다시 로그인하세요"MSG_RTT = "노드 핑을 측정하는 중이에요. 잠시 후 다시 켜 주세요"MSG_TIMEOUT = "30초 안에 연결되지 않아 연결을 취소했어요"MSG_MISSING = "데스크탑 모드에서 미꾸라지를 설치하세요 (docs.mudfish.net Steam Deck 가이드)"- 그 밖의 예외(
MudfishError, 네트워크 오류)는 phaseerror, message"연결 실패: <예외 메시지 한 줄>"로 처리한다.
state() -> dict(프론트 계약, 아래)connect() -> dict,disconnect() -> dict,set_node(choice: str) -> dict(choice는"auto"또는 sid 문자열),nodes() -> list[dict]({"sid","label","rtt"})- 모든 공개 메서드는
threading.Lock하나로 직렬화한다. 이미 연결 중일 때 들어온connect는 대기 후 현재 state를 돌려준다.
state() 계약 (Task 5, 6이 그대로 사용):
{"phase": "off|connecting|on|error|missing",
"message": "문구 또는 null",
"node_mode": "auto|manual", "node": {"sid": 11, "label": "Osaka - SakuraNet 4", "rtt": 31.0} ,
"ip": "1.2.3.4" , "country": "JP",
"connected_since": 1792840000.0}
missing:find_mudfish()가 None.on:core_running and client_ready and tap_up().connecting:core_running이지만 on이 아님. 그 외는off.error는 마지막 connect 실패 후 다음 상태 변화 전까지만 쓴다.node: manual이면 저장된 sid의 노드, auto면 지금 시점pick_auto결과(없으면 null).ip/country: phase가on일 때만 채운다(public_ip()를 30초 캐시).connected_since: on이면 store 값이고, store에 없으면 지금 시각을 저장해서 쓴다. off가 되면 store에서 지운다.
connect() 절차: ensure_daemon → ensure_signed_in(SigninError면 relogin 문구, error) → 노드 결정(auto면 pick_auto; None이면 rtt 문구, error, start 호출 안 함) → prog_conf를 읽고 mudfishFullvpnSid가 다르면 mudfishFullvpnSid=sid, mudfishFullvpnVid=0으로 save_prog_conf → store.routes_before = sorted(host_routes(route_text())) → start() → 1초 간격으로 최대 30초까지 on을 기다린다. 시간이 넘으면 stop() 후 timeout 문구, error. 성공하면 connected_since=clock().
set_node(choice): store에 저장한다. phase가 on이면 apply_node(Task 1 결과에 따라: save_prog_conf 응답의 need_restart가 참이면 stop() 후 connect 절차의 start부터, 아니면 fullvpn_refresh(sid, conf["mudfishFullvpnExceptions"]))를 실행한다. off면 계정 sid를 건드리지 않는다(다음 connect 때 적용).
disconnect(): stop() → 최대 10초 동안 tap_up()이 False가 되길 기다림 → delete_routes(leftover_routes(store.routes_before, route_text())) → store의 routes_before, connected_since를 비움.
- Step 1: 실패하는 테스트 작성
tests/test_client.py — 가짜 transport가 (method, url)별로 응답을 돌려주고 호출 기록을 남긴다:
test_ensure_signed_in_noop_when_signed— status가isSignedIn: true면 signin URL을 호출하지 않는다.test_ensure_signed_in_replays_autologin— status{"isSignedIn": false, "username": "u", "token_v3": "123", "item_conf_mode": "simple", "version": "6.6.0", "api_server": "api.mudfish.net"}일 때POST https://api.mudfish.net/signin_ng_v3본문(multipart)에autologin=ON,password=123이 들어가고, 이어POST <base>/do/authJSON에password: "123",remember_me: true,jwt(signin 응답값)가 들어간다.test_ensure_signed_in_raises_on_401— signin 응답{"status": 401}→SigninError.test_start_accepts_201/test_start_raises_on_500.test_save_prog_conf_sends_authorization_header— 헤더Authorization이 jwt 그대로다.
tests/test_controller.py — FakeClient, FakeSystem, 임시 디렉터리 Store, 고정 clock, no-op sleep을 쓴다. 노드 데이터는 test_nodes.py의 STATIC/RESP와 같은 값이다:
-
test_state_missing_when_not_installed→phase == "missing" -
test_state_off_never_starts_core→state()호출 후client.start_calls == 0 -
test_connect_auto_picks_lowest_and_saves_sid→ 저장된 conf의mudfishFullvpnSid == 11,mudfishFullvpnVid == 0, phaseon,connected_since == clock() -
test_connect_skips_save_when_sid_unchanged→save_calls == 0 -
test_connect_auto_without_rtts_does_not_start→ phaseerror, message가 표의 rtt 문구,start_calls == 0 -
test_connect_signin_failure_shows_relogin→ phaseerror, relogin 문구,start_calls == 0 -
test_connect_timeout_stops_core→ on이 끝내 안 되면stop_calls == 1, phaseerror, timeout 문구 -
test_disconnect_deletes_only_new_routes_and_keeps_sid→ 삭제 명령이 새로 생긴 2줄뿐이고save_calls가 늘지 않으며 store의connected_since가 None -
test_state_on_survives_restart_uses_saved_since→ 새 Controller(같은 store, store에connected_since=100.0, 가짜 상태는 on)의state()["connected_since"] == 100.0,start_calls == 0 -
test_set_node_while_off_only_saves_choice→save_calls == 0, storemanual_sid == 9 -
test_set_node_while_on_refreshes→need_restartfalse 응답이면fullvpn_refresh(9, ...)가 1회 호출된다 -
Step 2: 실패 확인
Run: bash scripts/test.sh
Expected: 새 테스트 FAIL(ImportError)
- Step 3:
client.py,system.py,store.py,controller.py를 위 Interfaces와 절차대로 구현
multipart 본문은 uuid4().hex 경계로 직접 만든다(스펙 4절과 같은 필드). /do/fullvpn/refresh는 application/x-www-form-urlencoded로 보낸다. 로그는 logging.getLogger("mudfish_decky")를 쓰고 jwt와 token은 로그에 남기지 않는다.
- Step 4: 통과 확인
Run: bash scripts/test.sh
Expected: 전부 OK
- Step 5: Commit
git add py_modules tests
git commit -m "feat: mudfish client and connect/disconnect controller"
Task 5: 데키 백엔드 연결
Files:
- Modify:
main.py - Create:
src/backend.ts
Interfaces:
-
Consumes: Task 4의
Controller,MudfishClient,system,Store -
Produces (
main.pyPlugin메서드, 모두async이고 내부에서await asyncio.to_thread(...)):get_state() -> dict,connect() -> dict,disconnect() -> dict,set_node(choice: str) -> dict,list_nodes() -> list[dict] -
Produces (
src/backend.ts):type Phase = "off"|"connecting"|"on"|"error"|"missing",interface VpnState(Task 4 계약과 같은 필드),interface NodeInfo {sid:number; label:string; rtt:number|null}, 그리고callable로 만든getState,connect,disconnect,setNode(choice: string),listNodes -
Step 1:
main.py구현
_main: Store(os.path.join(decky.DECKY_PLUGIN_SETTINGS_DIR, "settings.json"))로 Controller를 만들고, find_mudfish()가 있으면 ensure_daemon만 백그라운드 스레드로 실행한다(connect 호출 금지). logging.getLogger("mudfish_decky")에 decky.logger의 핸들러를 붙인다. _unload는 아무것도 하지 않는다(데몬과 연결 상태 유지).
- Step 2: 배포 후 백엔드 호출 확인
Run: bash scripts/deploy.sh, 그리고 ssh deck@192.168.1.50 'systemctl is-active mudfish-decky; journalctl -u plugin_loader --since "-1min" | grep -iE "mudfish|error"'
Expected: mudfish-decky가 active(Task 1의 mudfish-dev가 떠 있으면 먼저 sudo systemctl stop mudfish-dev). 로그에 traceback이 없다. curl -s 127.0.0.1:8282/do/mudrun/status의 core_running이 false다.
- Step 3: Commit
git add main.py src/backend.ts
git commit -m "feat: expose controller through decky backend"
Task 6: 패널 UI
Files:
- Create:
src/Panel.tsx - Modify:
src/index.tsx
Interfaces:
- Consumes: Task 5의
src/backend.ts
구성 (스펙 6절 순서): PanelSection 안에 차례로 둔다.
- 상태
Field: 라벨 "상태", 값은 phase 문구.- on이면
🇯🇵 JP · <ip> · <경과>를 표시한다. country가JP가 아니면 국가 코드를 그대로 쓰고 빨간 글씨로 표시한다. - 경과 시간은
connected_since기준으로 1분 미만 "방금", 60분 미만 "N분째", 그 이상 "N시간 M분째"로 표시한다.
- on이면
ToggleField"미꾸라지 Full VPN": 값은 phase가 on 또는 connecting이면 켜짐이다. connecting 동안과 missing일 때는 비활성화한다.- 노드
Field: auto면자동 · <label> (<rtt>ms), manual이면<label> (<rtt>ms), 노드가 없으면 "핑 측정 중…". ButtonItem"고급 ▸/▾"로 접고 편다. 펼치면DropdownItem의 첫 항목이 "자동 (핑 최저)"이고, 그 뒤로listNodes()순서대로<label> · <rtt>ms(rtt 없으면측정 안 됨)가 나온다. 선택하면setNode를 호출한다.- message가 있으면 한 줄
Field로 표시한다.
폴링: 패널이 마운트되어 있는 동안 2초마다 getState()를 호출한다. 노드 목록은 고급을 펼칠 때 한 번, 그리고 이후 10초마다 갱신한다.
문구 표 (백엔드 message와 UI에 그대로 사용):
| 키 | 문구 |
|---|---|
| phase off | 꺼짐 |
| phase connecting | 연결 중… |
| phase on | 연결됨 |
| phase error | 오류 |
| phase missing | 미꾸라지 미설치 |
| relogin | 데스크탑 모드에서 미꾸라지(127.0.0.1:8282)에 '로그인 기억하기'로 다시 로그인하세요 |
| rtt | 노드 핑을 측정하는 중이에요. 잠시 후 다시 켜 주세요 |
| timeout | 30초 안에 연결되지 않아 연결을 취소했어요 |
| missing | 데스크탑 모드에서 미꾸라지를 설치하세요 (docs.mudfish.net Steam Deck 가이드) |
relogin, rtt, timeout, missing 문구는 백엔드가 message로 보내 준다(Task 4의 MSG_* 상수). UI는 받은 message를 그대로 표시하고, phase 문구만 UI에서 정한다.
-
Step 1:
Panel.tsx구현,index.tsx에서 사용 -
Step 2: 타입 검사와 빌드
Run: pnpm build
Expected: 오류 없이 dist/index.js가 생성된다.
- Step 3: 배포 후 사용자와 함께 게임모드에서 확인
Run: bash scripts/deploy.sh
사용자에게 게임모드에서 다음 순서로 확인을 요청한다.
- 패널이 "꺼짐"이고 노드 줄에 "자동 · …"가 보인다.
- 토글을 켜면 "연결 중…"을 거쳐 "연결됨 · 🇯🇵 JP"가 된다.
- 고급에서 다른 노드를 고르면 IP가 바뀐다.
- 토글을 끄면 "꺼짐"이 된다.
그 사이 덱에서 ip -br link | grep tap; curl -s -m 8 https://ipinfo.io/json로 실제 상태를 대조한다.
Expected: 패널 표시와 실제 상태가 일치한다.
- Step 4: Commit
git add src
git commit -m "feat: quick access panel for Mudfish Full VPN"
Task 7: 실사용 검증과 정리
Files:
-
Modify:
docs/superpowers/specs/2026-10-24-mudfish-decky-design.md(실측 결과에 이어서 기록) -
Create:
README.md(설치 전제: 미꾸라지 설치, 데스크탑 모드에서 '로그인 기억하기'로 1회 로그인. 사용법 4줄. 문제가 생기면 볼 로그 위치) -
Step 1: 지포스나우 실사용
사용자에게 다음 순서로 진행해 달라고 요청한다: 게임모드 → 패널 켜기 → 일본 IP 확인 → 지포스나우 실행·로그인·게임 시작 → 패널 끄기 → 스트리밍이 유지되는지 확인. Expected: 사용자가 정상 동작을 확인한다.
-
Step 2: 재부팅과 절전
-
재부팅 후 패널이 "꺼짐"이고
core_running이 false다. -
켠 상태로 절전했다가 깨운 뒤 패널이 실제 상태(
tap0, ipinfo)와 일치한다.
결과를 스펙에 기록한다. 절전 후 터널이 죽었는데 core_running이 남는 등 불일치가 있으면 사용자에게 보고한다.
- Step 3: 개발용 설정 정리
Run: ssh deck@192.168.1.50 'sudo systemctl stop mudfish-dev 2>/dev/null; rm -rf ~/mudfish-dl /tmp/htd /tmp/mfd-test /tmp/mf.js'
사용자에게 Konsole에서 sudo rm /etc/sudoers.d/zz-dev-nopasswd를 직접 실행해 달라고 안내한다. 이후 ssh deck@192.168.1.50 'sudo -n true'가 실패하는 것으로 확인한다. 이 시점부터는 deploy.sh를 쓸 수 없다는 점을 README에 적는다.
- Step 4: Commit
git add README.md docs
git commit -m "docs: usage notes and on-device verification results"