diff --git a/README.md b/README.md index 24df38d..6f62b75 100644 --- a/README.md +++ b/README.md @@ -49,4 +49,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 토큰)를 씁니다. diff --git a/main.py b/main.py index 2a2274a..0de6a2b 100644 --- a/main.py +++ b/main.py @@ -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 @@ -41,6 +41,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) diff --git a/package.json b/package.json index 0a6ab21..77d88b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mudfish-decky", - "version": "0.1.1", + "version": "0.1.2", "description": "Toggle Mudfish Cloud VPN Full VPN mode from Steam Deck Gaming Mode", "type": "module", "scripts": { diff --git a/py_modules/mudfish_decky/selfupdate.py b/py_modules/mudfish_decky/selfupdate.py new file mode 100644 index 0000000..24363cc --- /dev/null +++ b/py_modules/mudfish_decky/selfupdate.py @@ -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': } 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"]} diff --git a/src/Panel.tsx b/src/Panel.tsx index bb6d77c..4a41c70 100644 --- a/src/Panel.tsx +++ b/src/Panel.tsx @@ -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([]); + const [release, setRelease] = useState(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 ( @@ -242,6 +283,21 @@ export function Panel() { 미꾸라지 제거 + + + {release ? `v${release.current}` : "…"} + {release?.latest + ? isNewer(release.latest.version, release.current) + ? ` · 최신 v${release.latest.version}` + : " (최신)" + : ""} + + + + + 최신 플러그인 주소 복사 + + )} diff --git a/src/backend.ts b/src/backend.ts index f8a4120..a897c9e 100644 --- a/src/backend.ts +++ b/src/backend.ts @@ -29,6 +29,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");