diff --git a/README.md b/README.md index 6f62b75..287c7f2 100644 --- a/README.md +++ b/README.md @@ -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. 노드는 기본이 **자동**이라 켤 때마다 핑이 가장 낮은 일본 노드를 고릅니다. 직접 고르려면 **고급 ▸**을 누르세요. ## 문제가 생기면 diff --git a/docs/superpowers/specs/2026-10-24-mudfish-decky-design.md b/docs/superpowers/specs/2026-10-24-mudfish-decky-design.md index 7ab1b9c..d574069 100644 --- a/docs/superpowers/specs/2026-10-24-mudfish-decky-design.md +++ b/docs/superpowers/specs/2026-10-24-mudfish-decky-design.md @@ -99,7 +99,7 @@ - **Full VPN 방식**만 지원한다. - 켜기와 끄기는 **사용자가 수동**으로 한다. 자동 끄기나 알림은 넣지 않는다. - 로그인은 데스크탑 모드에서 미리 한다(완료). 게임모드 로그인은 범위 밖이다. -- **끄기를 눌러도 계정의 Full VPN 노드 설정(sid)은 지우지 않는다.** +- ~~끄기를 눌러도 계정의 Full VPN 노드 설정(sid)은 지우지 않는다.~~ → **2026-10-26 변경(사용자 결정)**: 켤 때 바꾸기 직전 계정 값(`mudfishFullvpnSid/Vid`)을 저장해 두고, 터널을 내리는 모든 경로(끄기, 연결 실패, 절전 복귀, 종료, 제거)에서 그 값으로 되돌린다. 실패하면 대기로 남겨 플러그인 시작 시와 1분마다 다시 시도한다. - 노드 선택은 **기본이 "자동"**이고, 켤 때마다 그 시점에 핑이 가장 낮은 일본 노드를 고른다. **수동 선택은 "고급" 접기 메뉴 안에 숨긴다.** - 개발 중에는 Claude가 SSH로 스팀덱에 직접 배포하고 테스트한다. diff --git a/main.py b/main.py index 0de6a2b..f6cbb93 100644 --- a/main.py +++ b/main.py @@ -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 diff --git a/package.json b/package.json index 77d88b1..dea74f2 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/py_modules/mudfish_decky/controller.py b/py_modules/mudfish_decky/controller.py index b9bce3f..4824949 100644 --- a/py_modules/mudfish_decky/controller.py +++ b/py_modules/mudfish_decky/controller.py @@ -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(): - message = MSG_STILL_UP + 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: diff --git a/src/Panel.tsx b/src/Panel.tsx index 4a41c70..32d4129 100644 --- a/src/Panel.tsx +++ b/src/Panel.tsx @@ -242,6 +242,11 @@ export function Panel() { )} + {state.restore_pending && ( + + + + )} {state.need_login && ( showModal()}> diff --git a/src/backend.ts b/src/backend.ts index a897c9e..34c3292 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -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");