6 Commits
Author SHA1 Message Date
ysj7705andClaude Opus 5.5 9cf6efb8ad feat: put the account's Full VPN setting back when the VPN goes off; release 0.1.3
ysj7705/mudfish-decky/pipeline/head This commit looks good
The node the plugin sets is account-wide, so the PC's Mudfish would also
start in Full VPN. Remember the account's own value before changing it and
restore it on every teardown (off, connect failure, resume, unload,
uninstall); retry in the background if the restore fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:21:01 +09:00
ysj7705andClaude Opus 5.5 01c5f3f315 test: restore the account's Full VPN node when the tunnel goes down
ysj7705/mudfish-decky/pipeline/head There was a failure building this commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 21:18:33 +09:00
ysj7705andClaude Opus 5.5 d9015d43b9 feat: copy the latest plugin release URL from the panel; release 0.1.2
ysj7705/mudfish-decky/pipeline/head This commit looks good
Advanced shows the installed plugin version and the latest Gitea release.
'최신 플러그인 주소 복사' puts the release zip URL on the clipboard for
Decky's Install Plugin from URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:59:38 +09:00
ysj7705andClaude Opus 5.5 371f5a3814 test: look up the latest plugin release for the copy-URL button
ysj7705/mudfish-decky/pipeline/head There was a failure building this commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:57:58 +09:00
ysj7705andClaude Opus 5.5 991e7fbdc8 fix: leave source maps out of the plugin zip; release 0.1.1
ysj7705/mudfish-decky/pipeline/head This commit looks good
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:56:56 +09:00
ysj7705andClaude Opus 5.5 9e31508cb5 test: source maps must not be packaged
ysj7705/mudfish-decky/pipeline/head There was a failure building this commit
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:56:17 +09:00
12 changed files with 292 additions and 14 deletions

No files matched your search

+5 -2
View File
@@ -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. 노드는 기본이 **자동**이라 켤 때마다 핑이 가장 낮은 일본 노드를 고릅니다. 직접 고르려면 **고급 ▸**을 누르세요.
## 문제가 생기면
@@ -49,4 +50,6 @@
3. **Package**: `scripts/package.py`로 `out/mudfish-decky-<버전>.zip`을 만들고 아티팩트로 보관
4. **Release**: `scripts/gitea_release.py`로 Gitea 릴리스 `v<버전>`을 만들고 zip을 올림
덱에 설치된 플러그인을 업데이트하려면 패널의 **고급 ▸ → 최신 플러그인 주소 복사**를 누르세요. 데키 설정 → **Install Plugin from URL**에 붙여 넣으면 됩니다.
이미 같은 버전의 릴리스가 있으면 Release 단계는 아무것도 하지 않습니다. **새 버전을 내려면 `package.json`의 `version`을 올려서 푸시**하면 됩니다. 업로드에는 Jenkins 자격증명 `gitea-ysj7705`(저장소 쓰기 권한이 있는 Gitea 토큰)를 씁니다.
@@ -99,7 +99,7 @@
- **Full VPN 방식**만 지원한다.
- 켜기와 끄기는 **사용자가 수동**으로 한다. 자동 끄기나 알림은 넣지 않는다.
- 로그인은 데스크탑 모드에서 미리 한다(완료). 게임모드 로그인은 범위 밖이다.
- **끄기를 눌러도 계정의 Full VPN 노드 설정(sid)은 지우지 않는다.**
- ~~끄기를 눌러도 계정의 Full VPN 노드 설정(sid)은 지우지 않는다.~~ → **2026-10-26 변경(사용자 결정)**: 켤 때 바꾸기 직전 계정 값(`mudfishFullvpnSid/Vid`)을 저장해 두고, 터널을 내리는 모든 경로(끄기, 연결 실패, 절전 복귀, 종료, 제거)에서 그 값으로 되돌린다. 실패하면 대기로 남겨 플러그인 시작 시와 1분마다 다시 시도한다.
- 노드 선택은 **기본이 "자동"**이고, 켤 때마다 그 시점에 핑이 가장 낮은 일본 노드를 고른다. **수동 선택은 "고급" 접기 메뉴 안에 숨긴다.**
- 개발 중에는 Claude가 SSH로 스팀덱에 직접 배포하고 테스트한다.
+8 -1
View File
@@ -8,7 +8,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "py_modules"))
import decky # noqa: E402
from mudfish_decky import installer, system # noqa: E402
from mudfish_decky import installer, selfupdate, system # noqa: E402
from mudfish_decky.client import MudfishClient # noqa: E402
from mudfish_decky.controller import Controller # noqa: E402
from mudfish_decky.relay import LoginRelay # noqa: E402
@@ -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
@@ -41,6 +42,10 @@ class Plugin:
async def stop_login(self) -> dict:
return await asyncio.to_thread(self._get_controller().stop_login)
async def plugin_release(self) -> dict:
latest = await asyncio.to_thread(selfupdate.latest_plugin_release)
return {"current": decky.DECKY_PLUGIN_VERSION, "latest": latest}
async def install_mudfish(self) -> dict:
return await asyncio.to_thread(self._get_controller().install)
@@ -85,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
View File
@@ -1,6 +1,6 @@
{
"name": "mudfish-decky",
"version": "0.1.0",
"version": "0.1.3",
"description": "Toggle Mudfish Cloud VPN Full VPN mode from Steam Deck Gaming Mode",
"type": "module",
"scripts": {
+43 -3
View File
@@ -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:
+29
View File
@@ -0,0 +1,29 @@
"""Where to get this plugin's newest build: the latest Gitea release's zip (for Decky's Install from URL)."""
import json
import logging
import urllib.request
from typing import Optional
from .client import ssl_context
LATEST_RELEASE_API = "https://git.sejong.dev/api/v1/repos/ysj7705/mudfish-decky/releases/latest"
log = logging.getLogger("mudfish_decky")
def _fetch(url: str) -> str:
with urllib.request.urlopen(url, timeout=10, context=ssl_context()) as resp:
return resp.read().decode()
def latest_plugin_release(fetch=_fetch) -> Optional[dict]:
"""{'version': '0.1.2', 'url': <zip download URL>} or None when offline or no zip is attached."""
try:
release = json.loads(fetch(LATEST_RELEASE_API))
except Exception as e:
log.warning("plugin release lookup failed: %s", e)
return None
zips = [a for a in release.get("assets", []) if a.get("name", "").endswith(".zip")]
if not zips:
return None
return {"version": release.get("tag_name", "").lstrip("v"), "url": zips[0]["browser_download_url"]}
+2 -1
View File
@@ -26,7 +26,8 @@ def _files(src: str):
for root, dirs, files in os.walk(path):
dirs[:] = sorted(d for d in dirs if d != "__pycache__")
for name in sorted(files):
if not name.endswith(".pyc"):
# bytecode is rebuilt on the Deck; source maps are only for debugging
if not name.endswith((".pyc", ".map")):
yield os.path.relpath(os.path.join(root, name), src)
+61
View File
@@ -8,6 +8,7 @@ import {
showModal,
ToggleField,
} from "@decky/ui";
import { toaster } from "@decky/api";
import { LoginModal } from "./LoginModal";
import { useEffect, useRef, useState } from "react";
import {
@@ -21,6 +22,8 @@ import {
updateMudfish,
NodeInfo,
Phase,
PluginRelease,
pluginRelease,
setNode,
VpnState,
} from "./backend";
@@ -43,6 +46,26 @@ function elapsed(since: number, nowMs: number): string {
return `${Math.floor(minutes / 60)}시간 ${minutes % 60}분째`;
}
/** Copy via a hidden textarea (works in Steam's CEF where navigator.clipboard is often blocked). */
function copyText(text: string): boolean {
const el = document.createElement("textarea");
el.value = text;
el.style.position = "fixed";
el.style.opacity = "0";
document.body.appendChild(el);
el.focus();
el.select();
let ok = false;
try {
ok = document.execCommand("copy");
} catch {
ok = false;
}
el.remove();
if (!ok) navigator.clipboard?.writeText(text).catch(() => undefined);
return ok;
}
function rttText(rtt: number | null): string {
return rtt === null ? "측정 안 됨" : `${Math.round(rtt)}ms`;
}
@@ -71,6 +94,7 @@ export function Panel() {
const [busy, setBusy] = useState(false);
const [advanced, setAdvanced] = useState(false);
const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [release, setRelease] = useState<PluginRelease | null>(null);
const polling = useRef(false);
const acting = useRef(false);
@@ -94,6 +118,11 @@ export function Panel() {
return () => clearInterval(id);
}, []);
useEffect(() => {
// Fetch ahead of time so the copy happens synchronously inside the button press.
if (advanced) pluginRelease().then(setRelease).catch(() => setRelease(null));
}, [advanced]);
useEffect(() => {
if (!advanced) return;
const load = async () => {
@@ -154,6 +183,18 @@ export function Panel() {
/>,
);
const copyLatestUrl = () => {
const latest = release?.latest;
if (!latest) return;
const copied = copyText(latest.url);
toaster.toast({
title: "Mudfish VPN",
body: copied
? `v${latest.version} 주소를 복사했어요. 데키 설정 → Install Plugin from URL에 붙여 넣으세요`
: `복사하지 못했어요: ${latest.url}`,
});
};
if (missing) {
return (
<PanelSection>
@@ -201,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 />)}>
@@ -242,6 +288,21 @@ export function Panel() {
미꾸라지 제거
</ButtonItem>
</PanelSectionRow>
<PanelSectionRow>
<Field label="플러그인" focusable={false}>
{release ? `v${release.current}` : "…"}
{release?.latest
? isNewer(release.latest.version, release.current)
? ` · 최신 v${release.latest.version}`
: " (최신)"
: ""}
</Field>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem layout="below" disabled={!release?.latest} onClick={copyLatestUrl}>
최신 플러그인 주소 복사
</ButtonItem>
</PanelSectionRow>
</>
)}
</PanelSection>
+7
View File
@@ -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");
@@ -29,6 +30,12 @@ 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");
export interface PluginRelease {
current: string;
latest: { version: string; url: string } | null;
}
export const pluginRelease = callable<[], PluginRelease>("plugin_release");
export const installMudfish = callable<[], VpnState>("install_mudfish");
export const updateMudfish = callable<[], VpnState>("update_mudfish");
export const uninstallMudfish = callable<[], VpnState>("uninstall_mudfish");
+91 -5
View File
@@ -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
+1
View File
@@ -26,6 +26,7 @@ def make_tree(root, version="0.3.1"):
"main.py": "print('x')",
"README.md": "# readme",
"dist/index.js": "js",
"dist/index.js.map": "source map: debugging only, not shipped",
"py_modules/mudfish_decky/__init__.py": "",
"py_modules/mudfish_decky/nodes.py": "x = 1",
"py_modules/mudfish_decky/__pycache__/nodes.cpython-314.pyc": "junk",
+43
View File
@@ -0,0 +1,43 @@
import json
import unittest
from mudfish_decky.selfupdate import LATEST_RELEASE_API, latest_plugin_release
RELEASE = {
"tag_name": "v0.1.2",
"assets": [
{"name": "notes.txt", "browser_download_url": "https://git.example/notes.txt"},
{"name": "mudfish-decky-0.1.2.zip",
"browser_download_url": "https://git.sejong.dev/ysj7705/mudfish-decky/releases/download/v0.1.2/mudfish-decky-0.1.2.zip"},
],
}
class SelfUpdateTest(unittest.TestCase):
def test_reads_latest_release_zip(self):
urls = []
def fetch(url):
urls.append(url)
return json.dumps(RELEASE)
self.assertEqual(latest_plugin_release(fetch), {
"version": "0.1.2",
"url": "https://git.sejong.dev/ysj7705/mudfish-decky/releases/download/v0.1.2/mudfish-decky-0.1.2.zip",
})
self.assertEqual(urls, [LATEST_RELEASE_API])
self.assertEqual(LATEST_RELEASE_API,
"https://git.sejong.dev/api/v1/repos/ysj7705/mudfish-decky/releases/latest")
def test_none_when_release_has_no_zip(self):
self.assertIsNone(latest_plugin_release(lambda url: json.dumps({"tag_name": "v1", "assets": []})))
def test_none_when_unreachable(self):
def fetch(url):
raise OSError("offline")
self.assertIsNone(latest_plugin_release(fetch))
if __name__ == "__main__":
unittest.main()