2 Commits
Author SHA1 Message Date
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
7 changed files with 142 additions and 2 deletions

No files matched your search

+2
View File
@@ -49,4 +49,6 @@
3. **Package**: `scripts/package.py`로 `out/mudfish-decky-<버전>.zip`을 만들고 아티팩트로 보관 3. **Package**: `scripts/package.py`로 `out/mudfish-decky-<버전>.zip`을 만들고 아티팩트로 보관
4. **Release**: `scripts/gitea_release.py`로 Gitea 릴리스 `v<버전>`을 만들고 zip을 올림 4. **Release**: `scripts/gitea_release.py`로 Gitea 릴리스 `v<버전>`을 만들고 zip을 올림
덱에 설치된 플러그인을 업데이트하려면 패널의 **고급 ▸ → 최신 플러그인 주소 복사**를 누르세요. 데키 설정 → **Install Plugin from URL**에 붙여 넣으면 됩니다.
이미 같은 버전의 릴리스가 있으면 Release 단계는 아무것도 하지 않습니다. **새 버전을 내려면 `package.json`의 `version`을 올려서 푸시**하면 됩니다. 업로드에는 Jenkins 자격증명 `gitea-ysj7705`(저장소 쓰기 권한이 있는 Gitea 토큰)를 씁니다. 이미 같은 버전의 릴리스가 있으면 Release 단계는 아무것도 하지 않습니다. **새 버전을 내려면 `package.json`의 `version`을 올려서 푸시**하면 됩니다. 업로드에는 Jenkins 자격증명 `gitea-ysj7705`(저장소 쓰기 권한이 있는 Gitea 토큰)를 씁니다.
+5 -1
View File
@@ -8,7 +8,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "py_modules"))
import decky # noqa: E402 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.client import MudfishClient # noqa: E402
from mudfish_decky.controller import Controller # noqa: E402 from mudfish_decky.controller import Controller # noqa: E402
from mudfish_decky.relay import LoginRelay # noqa: E402 from mudfish_decky.relay import LoginRelay # noqa: E402
@@ -41,6 +41,10 @@ class Plugin:
async def stop_login(self) -> dict: async def stop_login(self) -> dict:
return await asyncio.to_thread(self._get_controller().stop_login) 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: async def install_mudfish(self) -> dict:
return await asyncio.to_thread(self._get_controller().install) return await asyncio.to_thread(self._get_controller().install)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "mudfish-decky", "name": "mudfish-decky",
"version": "0.1.1", "version": "0.1.2",
"description": "Toggle Mudfish Cloud VPN Full VPN mode from Steam Deck Gaming Mode", "description": "Toggle Mudfish Cloud VPN Full VPN mode from Steam Deck Gaming Mode",
"type": "module", "type": "module",
"scripts": { "scripts": {
+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"]}
+56
View File
@@ -8,6 +8,7 @@ import {
showModal, showModal,
ToggleField, ToggleField,
} from "@decky/ui"; } from "@decky/ui";
import { toaster } from "@decky/api";
import { LoginModal } from "./LoginModal"; import { LoginModal } from "./LoginModal";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
@@ -21,6 +22,8 @@ import {
updateMudfish, updateMudfish,
NodeInfo, NodeInfo,
Phase, Phase,
PluginRelease,
pluginRelease,
setNode, setNode,
VpnState, VpnState,
} from "./backend"; } from "./backend";
@@ -43,6 +46,26 @@ function elapsed(since: number, nowMs: number): string {
return `${Math.floor(minutes / 60)}시간 ${minutes % 60}분째`; 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 { function rttText(rtt: number | null): string {
return rtt === null ? "측정 안 됨" : `${Math.round(rtt)}ms`; return rtt === null ? "측정 안 됨" : `${Math.round(rtt)}ms`;
} }
@@ -71,6 +94,7 @@ export function Panel() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [advanced, setAdvanced] = useState(false); const [advanced, setAdvanced] = useState(false);
const [nodes, setNodes] = useState<NodeInfo[]>([]); const [nodes, setNodes] = useState<NodeInfo[]>([]);
const [release, setRelease] = useState<PluginRelease | null>(null);
const polling = useRef(false); const polling = useRef(false);
const acting = useRef(false); const acting = useRef(false);
@@ -94,6 +118,11 @@ export function Panel() {
return () => clearInterval(id); 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(() => { useEffect(() => {
if (!advanced) return; if (!advanced) return;
const load = async () => { 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) { if (missing) {
return ( return (
<PanelSection> <PanelSection>
@@ -242,6 +283,21 @@ export function Panel() {
미꾸라지 제거 미꾸라지 제거
</ButtonItem> </ButtonItem>
</PanelSectionRow> </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> </PanelSection>
+6
View File
@@ -29,6 +29,12 @@ export const setNode = callable<[choice: string], VpnState>("set_node");
export const listNodes = callable<[], NodeInfo[]>("list_nodes"); export const listNodes = callable<[], NodeInfo[]>("list_nodes");
export const startLogin = callable<[], VpnState>("start_login"); export const startLogin = callable<[], VpnState>("start_login");
export const stopLogin = callable<[], VpnState>("stop_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 installMudfish = callable<[], VpnState>("install_mudfish");
export const updateMudfish = callable<[], VpnState>("update_mudfish"); export const updateMudfish = callable<[], VpnState>("update_mudfish");
export const uninstallMudfish = callable<[], VpnState>("uninstall_mudfish"); export const uninstallMudfish = callable<[], VpnState>("uninstall_mudfish");
+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()