Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cf6efb8ad | ||
|
|
01c5f3f315 |
No files matched your search
@@ -13,7 +13,7 @@
|
||||
## 업데이트 / 제거
|
||||
|
||||
- **업데이트**: 하루에 한 번 최신 버전을 확인합니다. 새 버전이 있으면 **고급 ▸**에 업데이트 버튼이 나타납니다. VPN을 끈 상태에서만 할 수 있습니다. 로그인 설정은 새 버전으로 옮겨지고, 바로 전 버전 하나는 남깁니다.
|
||||
- **제거**: **고급 ▸ → 미꾸라지 제거**를 누릅니다. 또는 데키에서 플러그인을 삭제해도 같이 정리됩니다. 정리 대상은 VPN 연결, 데몬, `/opt/mudfish` 전체, `mudfish-tap` 연결, 플러그인 설정입니다. 미꾸라지 계정에 저장된 Full VPN 노드 설정은 남습니다.
|
||||
- **제거**: **고급 ▸ → 미꾸라지 제거**를 누릅니다. 또는 데키에서 플러그인을 삭제해도 같이 정리됩니다. 정리 대상은 VPN 연결, 데몬, `/opt/mudfish` 전체, `mudfish-tap` 연결, 플러그인 설정입니다. 미꾸라지 계정의 Full VPN 노드 설정은 켜기 전 값으로 되돌려 둡니다.
|
||||
- 데키 3.3은 플러그인을 5초 만에 강제 종료하는 경우가 잦습니다. 그래서 별도의 systemd 감시(`mudfish-decky-cleanup.path`)가 플러그인 폴더를 지켜보다가, 폴더가 20초 동안 사라져 있으면 정리를 실행합니다.
|
||||
- 공식 설치 방법은 [Steam Deck 가이드](https://docs.mudfish.net/v3/en/docs/mudfish-product/mudfish-cloud-vpn/platform/steamos/steamos-install)를 참고하세요.
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
1. `...` → 데키 → **Mudfish VPN**에서 토글을 켭니다.
|
||||
2. 상태가 `연결됨 · 🇯🇵 JP`로 바뀌면 지포스나우를 실행해서 로그인하고 게임을 시작합니다.
|
||||
3. 게임이 시작되면 토글을 끕니다. 미꾸라지는 1GB당 135원이고 경유 트래픽은 두 번 집계되므로, 켜 둔 채 스트리밍하지 마세요.
|
||||
4. 노드는 기본이 **자동**이라 켤 때마다 핑이 가장 낮은 일본 노드를 고릅니다. 직접 고르려면 **고급 ▸**을 누르세요.
|
||||
4. 끄면 미꾸라지 계정의 Full VPN 설정도 켜기 전 값으로 되돌아갑니다(PC의 미꾸라지에 영향 없음).
|
||||
5. 노드는 기본이 **자동**이라 켤 때마다 핑이 가장 낮은 일본 노드를 고릅니다. 직접 고르려면 **고급 ▸**을 누르세요.
|
||||
|
||||
## 문제가 생기면
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
- **Full VPN 방식**만 지원한다.
|
||||
- 켜기와 끄기는 **사용자가 수동**으로 한다. 자동 끄기나 알림은 넣지 않는다.
|
||||
- 로그인은 데스크탑 모드에서 미리 한다(완료). 게임모드 로그인은 범위 밖이다.
|
||||
- **끄기를 눌러도 계정의 Full VPN 노드 설정(sid)은 지우지 않는다.**
|
||||
- ~~끄기를 눌러도 계정의 Full VPN 노드 설정(sid)은 지우지 않는다.~~ → **2026-10-26 변경(사용자 결정)**: 켤 때 바꾸기 직전 계정 값(`mudfishFullvpnSid/Vid`)을 저장해 두고, 터널을 내리는 모든 경로(끄기, 연결 실패, 절전 복귀, 종료, 제거)에서 그 값으로 되돌린다. 실패하면 대기로 남겨 플러그인 시작 시와 1분마다 다시 시도한다.
|
||||
- 노드 선택은 **기본이 "자동"**이고, 켤 때마다 그 시점에 핑이 가장 낮은 일본 노드를 고른다. **수동 선택은 "고급" 접기 메뉴 안에 숨긴다.**
|
||||
- 개발 중에는 Claude가 SSH로 스팀덱에 직접 배포하고 테스트한다.
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from mudfish_decky.store import Store # noqa: E402
|
||||
|
||||
RESUME_POLL_SECONDS = 2
|
||||
LATEST_POLL_TICKS = 1800 # every hour at 2s per tick
|
||||
RESTORE_RETRY_TICKS = 30 # retry a pending account restore every minute
|
||||
LOGIN_RELAY_SECONDS = 300
|
||||
|
||||
|
||||
@@ -89,6 +90,8 @@ class Plugin:
|
||||
controller.check_resume(system.sleep_offset())
|
||||
if ticks % LATEST_POLL_TICKS == 0:
|
||||
await asyncio.to_thread(controller.refresh_latest)
|
||||
if ticks % RESTORE_RETRY_TICKS == 0:
|
||||
await asyncio.to_thread(controller.retry_restore)
|
||||
except Exception as e:
|
||||
decky.logger.warning("background check failed: %s", e)
|
||||
ticks += 1
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mudfish-decky",
|
||||
"version": "0.1.2",
|
||||
"version": "0.1.3",
|
||||
"description": "Toggle Mudfish Cloud VPN Full VPN mode from Steam Deck Gaming Mode",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -85,6 +85,8 @@ class Controller:
|
||||
try:
|
||||
st = self._signed_in(installed)
|
||||
self._static_nodes(st)
|
||||
if not self._tunnel_present():
|
||||
self._restore_account() # a restore left pending by the last session
|
||||
except SigninError as e:
|
||||
log.warning("prepare: saved login rejected: %s", e)
|
||||
self._error = MSG_RELOGIN
|
||||
@@ -168,6 +170,7 @@ class Controller:
|
||||
try:
|
||||
if self._tunnel_present():
|
||||
self._teardown()
|
||||
self._restore_account() # needs the daemon's session, so before stopping it
|
||||
except Exception as e:
|
||||
log.warning("uninstall: teardown failed: %s", e)
|
||||
for step in (lambda: self.system.stop_daemon(wait=False), self.system.remove_nm_connection,
|
||||
@@ -286,6 +289,12 @@ class Controller:
|
||||
self._close_relay()
|
||||
return self._state()
|
||||
|
||||
def retry_restore(self) -> None:
|
||||
"""Background retry of a pending account restore; never while a tunnel is up."""
|
||||
with self._lock:
|
||||
if self.store.load().get("fullvpn_restore") and not self._tunnel_present():
|
||||
self._restore_account()
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Plugin unload (Deck shutdown/restart, Decky restart): never leave a tunnel or relay behind."""
|
||||
with self._lock:
|
||||
@@ -312,8 +321,11 @@ class Controller:
|
||||
def _abort(self, started: bool, message: str) -> dict:
|
||||
if message == MSG_RELOGIN:
|
||||
self._mark_need_login()
|
||||
if started and not self._teardown():
|
||||
if started:
|
||||
if not self._teardown():
|
||||
message = MSG_STILL_UP
|
||||
else:
|
||||
self._restore_account() # the account may already point at our node
|
||||
return self._fail(message)
|
||||
|
||||
def _track_login(self, st: Optional[dict]) -> None:
|
||||
@@ -361,6 +373,29 @@ class Controller:
|
||||
log.warning("route cleanup failed: %s", e)
|
||||
self.store.update(routes_before=None, connected_since=None, active_sid=None)
|
||||
self._ip_cache = (0.0, None)
|
||||
self._restore_account()
|
||||
return True
|
||||
|
||||
def _restore_account(self) -> bool:
|
||||
"""Put the account's Full VPN node back to what it was before we connected.
|
||||
On failure the saved value stays and retry_restore()/prepare() try again later."""
|
||||
saved = self.store.load().get("fullvpn_restore")
|
||||
if not saved:
|
||||
return True
|
||||
|
||||
def put_back(st):
|
||||
conf = self.client.prog_conf(st["jwt"], st["api_server"])
|
||||
if (conf.get("mudfishFullvpnSid") or 0, conf.get("mudfishFullvpnVid") or 0) != (saved["sid"], saved["vid"]):
|
||||
conf.update(mudfishFullvpnSid=saved["sid"], mudfishFullvpnVid=saved["vid"])
|
||||
self.client.save_prog_conf(st["jwt"], st["api_server"], conf)
|
||||
|
||||
try:
|
||||
self._with_auth_retry(self.client.ensure_signed_in(), put_back)
|
||||
except Exception as e:
|
||||
log.warning("restoring the account Full VPN setting failed: %s", e)
|
||||
return False
|
||||
self.store.update(fullvpn_restore=None)
|
||||
log.info("account Full VPN setting restored (sid=%s)", saved["sid"])
|
||||
return True
|
||||
|
||||
def _is_on(self, st: dict) -> bool:
|
||||
@@ -439,6 +474,10 @@ class Controller:
|
||||
conf = self.client.prog_conf(st["jwt"], st["api_server"])
|
||||
if conf.get("mudfishFullvpnSid") == sid and not conf.get("mudfishFullvpnVid"):
|
||||
return {"need_restart": False, "conf": conf, "changed": False}
|
||||
if not self.store.load().get("fullvpn_restore"):
|
||||
# Remember the account's own setting (e.g. what the PC uses) once, to put back on teardown.
|
||||
self.store.update(fullvpn_restore={"sid": conf.get("mudfishFullvpnSid") or 0,
|
||||
"vid": conf.get("mudfishFullvpnVid") or 0})
|
||||
conf.update(mudfishFullvpnSid=sid, mudfishFullvpnVid=0)
|
||||
resp = self.client.save_prog_conf(st["jwt"], st["api_server"], conf)
|
||||
return {"need_restart": bool(resp.get("need_restart")), "conf": conf, "changed": True}
|
||||
@@ -473,7 +512,7 @@ class Controller:
|
||||
data = self.store.load()
|
||||
out = {"phase": "off", "message": None, "node_mode": data.get("node_mode") or "auto", "node": None,
|
||||
"ip": None, "country": None, "connected_since": None, "tunnel": False,
|
||||
"need_login": False, "login": None, "mudfish": None}
|
||||
"need_login": False, "login": None, "mudfish": None, "restore_pending": False}
|
||||
installed = self.system.find_mudfish()
|
||||
out["mudfish"] = {"version": self.system.version_of(installed[0]) if installed else None,
|
||||
"latest": data.get("latest_version")}
|
||||
@@ -491,6 +530,7 @@ class Controller:
|
||||
tap = self.system.tap_up()
|
||||
on = bool(st) and self._is_on(st)
|
||||
out["tunnel"] = tap or bool(st and st.get("core_running"))
|
||||
out["restore_pending"] = bool(self.store.load().get("fullvpn_restore")) and not out["tunnel"]
|
||||
if st:
|
||||
self._refresh_rtts()
|
||||
if on:
|
||||
|
||||
@@ -242,6 +242,11 @@ export function Panel() {
|
||||
<Field focusable={false} description={state.message} />
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{state.restore_pending && (
|
||||
<PanelSectionRow>
|
||||
<Field focusable={false} description="계정 Full VPN 설정 복구 대기 중 (자동으로 다시 시도해요)" />
|
||||
</PanelSectionRow>
|
||||
)}
|
||||
{state.need_login && (
|
||||
<PanelSectionRow>
|
||||
<ButtonItem layout="below" onClick={() => showModal(<LoginModal />)}>
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface VpnState {
|
||||
need_login: boolean;
|
||||
login: { url: string; expires_at: number } | null;
|
||||
mudfish: { version: string | null; latest: string | null } | null;
|
||||
restore_pending: boolean;
|
||||
}
|
||||
|
||||
export const getState = callable<[], VpnState>("get_state");
|
||||
|
||||
@@ -2,7 +2,7 @@ import os
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from mudfish_decky.client import AuthError, SigninError
|
||||
from mudfish_decky.client import AuthError, MudfishError, SigninError
|
||||
from mudfish_decky.controller import (MSG_INSTALLED, MSG_LOGGED_IN, MSG_MISSING, MSG_NO_LAN, MSG_NO_RESPONSE,
|
||||
MSG_RELOGIN, MSG_REMOVED, MSG_RTT, MSG_SLEEP, MSG_STILL_UP, MSG_TIMEOUT,
|
||||
MSG_UP_TO_DATE, MSG_UPDATE_NEED_OFF, MSG_UPDATED, Controller)
|
||||
@@ -246,18 +246,104 @@ class ControllerTest(unittest.TestCase):
|
||||
c.connect()
|
||||
self.assertEqual(self.client.conf["mudfishFullvpnSid"], 9)
|
||||
|
||||
def test_disconnect_deletes_only_new_routes_and_keeps_sid(self):
|
||||
def test_disconnect_deletes_only_new_routes(self):
|
||||
c = self.make()
|
||||
c.connect()
|
||||
saves = len(self.client.saved)
|
||||
st = c.disconnect()
|
||||
self.assertEqual(self.system.deleted,
|
||||
["1.201.174.247 via 192.168.1.1 dev wlan0", "2.58.243.110 via 192.168.1.1 dev wlan0"])
|
||||
self.assertEqual(len(self.client.saved), saves)
|
||||
self.assertEqual(self.client.conf["mudfishFullvpnSid"], 11)
|
||||
self.assertEqual(st["phase"], "off")
|
||||
self.assertIsNone(Store(self.path).load().get("connected_since"))
|
||||
|
||||
# ---- account Full VPN setting is restored when the tunnel goes down ------------------------
|
||||
|
||||
def test_connect_remembers_original_fullvpn_setting(self):
|
||||
self.client.conf.update(mudfishFullvpnSid=0, mudfishFullvpnVid=5)
|
||||
self.make().connect()
|
||||
self.assertEqual(Store(self.path).load()["fullvpn_restore"], {"sid": 0, "vid": 5})
|
||||
|
||||
def test_disconnect_restores_original_fullvpn_setting(self):
|
||||
self.client.conf.update(mudfishFullvpnSid=0, mudfishFullvpnVid=5)
|
||||
c = self.make()
|
||||
c.connect()
|
||||
st = c.disconnect()
|
||||
self.assertEqual((self.client.conf["mudfishFullvpnSid"], self.client.conf["mudfishFullvpnVid"]), (0, 5))
|
||||
self.assertEqual(self.client.conf["mudfishMtu"], 1350)
|
||||
self.assertIsNone(Store(self.path).load().get("fullvpn_restore"))
|
||||
self.assertFalse(st["restore_pending"])
|
||||
|
||||
def test_restore_keeps_first_original_across_node_change(self):
|
||||
c = self.make()
|
||||
c.connect() # 0 -> 11
|
||||
self.client.need_restart = False
|
||||
c.set_node("9") # 11 -> 9 while on
|
||||
c.disconnect()
|
||||
self.assertEqual(self.client.conf["mudfishFullvpnSid"], 0)
|
||||
|
||||
def test_no_restore_when_account_already_on_target_node(self):
|
||||
self.client.conf["mudfishFullvpnSid"] = 11
|
||||
c = self.make()
|
||||
c.connect()
|
||||
c.disconnect()
|
||||
self.assertEqual(self.client.saved, [])
|
||||
self.assertEqual(self.client.conf["mudfishFullvpnSid"], 11)
|
||||
|
||||
def test_restore_failure_stays_pending_and_retries(self):
|
||||
c = self.make()
|
||||
c.connect()
|
||||
real_save = self.client.save_prog_conf
|
||||
|
||||
def failing(jwt, api, conf):
|
||||
raise MudfishError("network down")
|
||||
|
||||
self.client.save_prog_conf = failing
|
||||
st = c.disconnect()
|
||||
self.assertEqual(st["phase"], "off")
|
||||
self.assertTrue(st["restore_pending"])
|
||||
self.assertEqual(Store(self.path).load()["fullvpn_restore"], {"sid": 0, "vid": 0})
|
||||
self.client.save_prog_conf = real_save
|
||||
c.retry_restore()
|
||||
self.assertEqual(self.client.conf["mudfishFullvpnSid"], 0)
|
||||
self.assertFalse(c.state()["restore_pending"])
|
||||
|
||||
def test_retry_restore_waits_while_tunnel_up(self):
|
||||
c = self.make()
|
||||
c.connect()
|
||||
saves = len(self.client.saved)
|
||||
c.retry_restore()
|
||||
self.assertEqual(len(self.client.saved), saves)
|
||||
self.assertEqual(self.client.conf["mudfishFullvpnSid"], 11)
|
||||
|
||||
def test_connect_timeout_restores_original(self):
|
||||
self.client.ready_on_start = False
|
||||
self.make().connect()
|
||||
self.assertEqual(self.client.conf["mudfishFullvpnSid"], 0)
|
||||
|
||||
def test_resume_restores_original(self):
|
||||
c = self.make()
|
||||
c.connect()
|
||||
c.check_resume(5.0)
|
||||
c.check_resume(100.0)
|
||||
self.assertEqual(self.client.conf["mudfishFullvpnSid"], 0)
|
||||
|
||||
def test_shutdown_restores_original(self):
|
||||
c = self.make()
|
||||
c.connect()
|
||||
c.shutdown()
|
||||
self.assertEqual(self.client.conf["mudfishFullvpnSid"], 0)
|
||||
|
||||
def test_uninstall_restores_before_removing_mudfish(self):
|
||||
c = self.make_with_installer()
|
||||
c.connect()
|
||||
order = []
|
||||
real_save = self.client.save_prog_conf
|
||||
self.client.save_prog_conf = lambda j, a, conf: order.append("restore") or real_save(j, a, conf)
|
||||
real_remove = self.installer.remove_all
|
||||
self.installer.remove_all = lambda root: order.append("remove") or real_remove(root)
|
||||
c.uninstall()
|
||||
self.assertEqual(order, ["restore", "remove"])
|
||||
self.assertEqual(self.client.conf["mudfishFullvpnSid"], 0)
|
||||
|
||||
def test_disconnect_without_snapshot_deletes_nothing(self):
|
||||
self.client.st.update(core_running=True, client_ready=True)
|
||||
self.system.tap = True
|
||||
|
||||
Reference in new issue
Block a user