Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions plugin.json
Original file line number Diff line number Diff line change
@@ -1,18 +1,11 @@
{
"name": "Decky proton launch",
"author": "moi952",
"version": "0.8.0",
"flags": [
"debug",
"_root"
],
"version": "0.1.0",
"flags": ["debug", "_root"],
"api_version": 1,
"publish": {
"tags": [
"proton",
"launch",
"command"
],
"tags": ["proton", "launch", "command"],
"description": "Manage Proton launch options easily on Steam",
"image": "https://raw.githubusercontent.com/moi952/decky-proton-launch/main/assets/favorite_shortcuts.JPG"
}
Expand Down
6 changes: 6 additions & 0 deletions py_modules/proton_launch/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .steam import get_steam_roots, get_user_dirs, get_all_steamapps_dirs, get_shortcuts_paths, get_shortcut_name
from .profile import profiles_dir, script_path, profile_path, chown_to_user, write_profile, read_profile
from .launch_option import LAUNCH_OPTION, set_launch_option, remove_launch_option, get_status
from .updater import perform_update as _perform_update


class Plugin:
Expand Down Expand Up @@ -511,6 +512,11 @@ async def clear_variables_cache(self) -> bool:
decky.logger.error(f"[clear_variables_cache] {e}")
return False

# ── Self-update ─────────────────────────────────────────────────────────────

async def perform_update(self) -> Dict[str, Any]:
return await _perform_update()

# ── Lifecycle ───────────────────────────────────────────────────────────────

async def _main(self):
Expand Down
79 changes: 79 additions & 0 deletions py_modules/proton_launch/updater.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import asyncio
import json
import shutil
import ssl
import tempfile
import traceback
import urllib.request
import zipfile
from pathlib import Path
from typing import Any, Dict

import decky

REPO = "moi952/decky-proton-launch"
_API_URL = f"https://api.github.com/repos/{REPO}/releases/latest"
_HEADERS = {"User-Agent": "decky-proton-launch"}
_SSL_CTX = ssl.create_default_context()
_SSL_CTX.check_hostname = False
_SSL_CTX.verify_mode = ssl.CERT_NONE


def _fetch_json(url: str) -> Any:
req = urllib.request.Request(url, headers=_HEADERS)
with urllib.request.urlopen(req, timeout=15, context=_SSL_CTX) as r:
return json.loads(r.read().decode())


def _download_bytes(url: str) -> bytes:
req = urllib.request.Request(url, headers=_HEADERS)
with urllib.request.urlopen(req, timeout=60, context=_SSL_CTX) as r:
return r.read()


async def perform_update() -> Dict[str, Any]:
"""Download the latest GitHub release zip and replace plugin files.

Safe on Linux: the running process keeps old code in RAM; Decky reloads
the plugin after this returns, picking up the new files from disk.
"""
try:
decky.logger.info("[updater] fetching release metadata")
release = await asyncio.to_thread(_fetch_json, _API_URL)
tag = release.get("tag_name", "")
assets = release.get("assets", [])
zip_asset = next((a for a in assets if a["name"].endswith(".zip")), None)
if not zip_asset:
return {"success": False, "error": "no_zip_asset"}

zip_url = zip_asset["browser_download_url"]
decky.logger.info(f"[updater] downloading {zip_url}")

zip_bytes = await asyncio.to_thread(_download_bytes, zip_url)
decky.logger.info(f"[updater] download complete ({len(zip_bytes)} bytes)")

with tempfile.TemporaryDirectory() as tmp:
zip_path = Path(tmp) / "update.zip"
zip_path.write_bytes(zip_bytes)

with zipfile.ZipFile(zip_path) as zf:
zf.extractall(tmp)

src = next(
(d for d in Path(tmp).iterdir() if d.is_dir() and d.name != "__MACOSX"),
None,
)
if not src:
return {"success": False, "error": "bad_zip_structure"}

dest = Path(decky.DECKY_PLUGIN_DIR)
decky.logger.info(f"[updater] replacing {dest}")
shutil.rmtree(dest)
shutil.copytree(src, dest)

decky.logger.info(f"[updater] updated to {tag}")
return {"success": True, "version": tag}

except Exception as e:
decky.logger.error(f"[updater] {e}\n{traceback.format_exc()}")
return {"success": False, "error": str(e)}
31 changes: 28 additions & 3 deletions src/components/UpdateBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { DialogButton } from "@decky/ui";
import { call, toaster } from "@decky/api";
import { useTranslation } from "react-i18next";
// @ts-ignore — replaced at build time by rollup with the content of plugin.json
import manifest from "@decky/manifest";
Expand All @@ -8,7 +9,6 @@ const CURRENT_VERSION: string = manifest?.version ?? "0.0.0";

const REPO = "moi952/decky-proton-launch";
const RELEASES_URL = `https://api.github.com/repos/${REPO}/releases/latest`;
const RELEASES_PAGE = `https://github.com/${REPO}/releases/latest`;

function semverGt(a: string, b: string): boolean {
const pa = a.replace(/^v/, "").split(".").map(Number);
Expand All @@ -23,6 +23,7 @@ function semverGt(a: string, b: string): boolean {
export const UpdateBanner: React.FC = () => {
const { t } = useTranslation("common");
const [latestVersion, setLatestVersion] = useState<string | null>(null);
const [updating, setUpdating] = useState(false);

useEffect(() => {
fetch(RELEASES_URL)
Expand All @@ -38,6 +39,30 @@ export const UpdateBanner: React.FC = () => {

if (!latestVersion) return null;

const handleUpdate = async () => {
setUpdating(true);
try {
const result = await call<[], { success: boolean; version?: string; error?: string }>(
"perform_update",
);
if (result.success) {
toaster.toast({
title: "Proton Launch",
body: t("update_success", { version: result.version }),
});
setTimeout(() => {
(window as any).DeckyPluginLoader?.reloadPlugin?.("decky-proton-launch");
}, 1500);
} else {
toaster.toast({ title: t("update_failed"), body: result.error ?? "unknown error" });
setUpdating(false);
}
} catch (e) {
toaster.toast({ title: t("update_failed"), body: String(e) });
setUpdating(false);
}
};

return (
<div style={{ margin: "0 16px 8px" }}>
<style>{`
Expand All @@ -49,7 +74,7 @@ export const UpdateBanner: React.FC = () => {
`}</style>
<DialogButton
className="plch-update-btn"
onClick={() => window.open(RELEASES_PAGE, "_blank")}
onClick={updating ? () => {} : handleUpdate}
style={{
padding: "6px 10px",
background: "#1a2a1a",
Expand All @@ -61,7 +86,7 @@ export const UpdateBanner: React.FC = () => {
width: "100%",
}}
>
{t("update_available", { version: latestVersion })}
{updating ? "Updating…" : t("update_available", { version: latestVersion })}
</DialogButton>
</div>
);
Expand Down
42 changes: 34 additions & 8 deletions src/context/RemoteDataContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,15 @@ export type VariableCategory = {

type RemoteData = {
variables: VariableCategory[];
locales: Record<string, { variables: Record<string, string>; categories: Record<string, string> }>;
locales: Record<
string,
{ variables: Record<string, string>; categories: Record<string, string> }
>;
};

interface RemoteDataContextValue {
variables: VariableCategory[];
noData: boolean; // true when cache empty AND fetch failed/empty
noData: boolean;
refresh: () => void;
}

Expand All @@ -41,18 +44,39 @@ export const useRemoteData = () => useContext(RemoteDataContext);

const getLangCode = (): string => {
const supported = [
"en-US", "fr-FR", "de-DE", "es-ES", "it-IT", "ja-JP",
"ko-KR", "nl-NL", "pl-PL", "pt-BR", "pt-PT", "ru-RU",
"tr-TR", "uk-UA", "zh-CN",
"en-US",
"fr-FR",
"de-DE",
"es-ES",
"it-IT",
"ja-JP",
"ko-KR",
"nl-NL",
"pl-PL",
"pt-BR",
"pt-PT",
"ru-RU",
"tr-TR",
"uk-UA",
"zh-CN",
];
const lang = i18n.language || "en-US";
if (supported.includes(lang)) return lang;
const prefix = lang.split("-")[0];
return supported.find((s) => s.startsWith(prefix)) ?? "en-US";
};

const injectTranslations = (lang: string, localeData: RemoteData["locales"][string]) => {
i18n.addResourceBundle(lang, "categories", localeData.categories, true, false);
const injectTranslations = (
lang: string,
localeData: RemoteData["locales"][string],
) => {
i18n.addResourceBundle(
lang,
"categories",
localeData.categories,
true,
false,
);
i18n.addResourceBundle(lang, "variables", localeData.variables, true, true);
};

Expand All @@ -72,7 +96,9 @@ const loadData = (
) => {
let hasData = false;

const cachePromise = call<[], Record<string, any>>("get_variables_cache").then((cached) => {
const cachePromise = call<[], Record<string, any>>(
"get_variables_cache",
).then((cached) => {
if (cached && (cached as RemoteData).variables?.length) {
hasData = true;
applyData(cached as RemoteData, setVariables);
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/locales/de-DE.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
"delete": "Löschen",
"clean": "Leeren",
"update_available": "↑ v{{version}} verfügbar — zum Herunterladen tippen",
"no_data": "Eine Internetverbindung ist erforderlich, um die Variablen zu laden."
"no_data": "Eine Internetverbindung ist erforderlich, um die Variablen zu laden.",
"update_installing": "Aktualisierung läuft…",
"update_success": "Aktualisiert auf v{{version}} — wird neu geladen…",
"update_failed": "Aktualisierung fehlgeschlagen"
},
"delete_favorite_modal": {
"title": "Favorit löschen",
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
"delete": "Delete",
"clean": "Clean",
"update_available": "↑ v{{version}} available — tap to download",
"no_data": "An internet connection is required to load the variables."
"no_data": "An internet connection is required to load the variables.",
"update_installing": "Updating…",
"update_success": "Updated to v{{version}} — reloading…",
"update_failed": "Update failed"
},
"delete_favorite_modal": {
"title": "Delete Favorite",
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/locales/es-ES.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
"delete": "Eliminar",
"clean": "Limpiar",
"update_available": "↑ v{{version}} disponible — pulsar para descargar",
"no_data": "Se requiere conexión a internet para cargar las variables."
"no_data": "Se requiere conexión a internet para cargar las variables.",
"update_installing": "Actualizando…",
"update_success": "Actualizado a v{{version}} — recargando…",
"update_failed": "Error al actualizar"
},
"delete_favorite_modal": {
"title": "Eliminar favorito",
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/locales/fr-FR.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
"delete": "Supprimer",
"clean": "Nettoyer",
"update_available": "↑ v{{version}} disponible — appuyer pour télécharger",
"no_data": "Une connexion internet est requise pour charger les variables."
"no_data": "Une connexion internet est requise pour charger les variables.",
"update_installing": "Mise à jour en cours…",
"update_success": "Mis à jour vers v{{version}} — rechargement…",
"update_failed": "Échec de la mise à jour"
},
"delete_favorite_modal": {
"title": "Supprimer des favoris",
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/locales/it-IT.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
"delete": "Elimina",
"clean": "Pulisci",
"update_available": "↑ v{{version}} disponibile — tocca per scaricare",
"no_data": "È necessaria una connessione internet per caricare le variabili."
"no_data": "È necessaria una connessione internet per caricare le variabili.",
"update_installing": "Aggiornamento in corso…",
"update_success": "Aggiornato a v{{version}} — ricaricamento…",
"update_failed": "Aggiornamento fallito"
},
"delete_favorite_modal": {
"title": "Elimina preferito",
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/locales/ja-JP.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
"delete": "削除",
"clean": "クリア",
"update_available": "↑ v{{version}} が利用可能 — タップしてダウンロード",
"no_data": "変数を読み込むにはインターネット接続が必要です。"
"no_data": "変数を読み込むにはインターネット接続が必要です。",
"update_installing": "更新中…",
"update_success": "v{{version}} に更新しました — 再読み込み中…",
"update_failed": "更新に失敗しました"
},
"delete_favorite_modal": {
"title": "お気に入り削除",
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/locales/ko-KR.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
"delete": "삭제",
"clean": "지우기",
"update_available": "↑ v{{version}} 업데이트 가능 — 탭하여 다운로드",
"no_data": "변수를 불러오려면 인터넷 연결이 필요합니다."
"no_data": "변수를 불러오려면 인터넷 연결이 필요합니다.",
"update_installing": "업데이트 중…",
"update_success": "v{{version}}으로 업데이트됨 — 다시 로드 중…",
"update_failed": "업데이트 실패"
},
"delete_favorite_modal": {
"title": "즐겨찾기 삭제",
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/locales/nl-NL.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
"delete": "Verwijderen",
"clean": "Wissen",
"update_available": "↑ v{{version}} beschikbaar — tik om te downloaden",
"no_data": "Een internetverbinding is vereist om de variabelen te laden."
"no_data": "Een internetverbinding is vereist om de variabelen te laden.",
"update_installing": "Bijwerken…",
"update_success": "Bijgewerkt naar v{{version}} — herladen…",
"update_failed": "Update mislukt"
},
"delete_favorite_modal": {
"title": "Favoriet verwijderen",
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/locales/pl-PL.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
"delete": "Usuń",
"clean": "Wyczyść",
"update_available": "↑ v{{version}} dostępna — naciśnij, aby pobrać",
"no_data": "Połączenie z internetem jest wymagane do załadowania zmiennych."
"no_data": "Połączenie z internetem jest wymagane do załadowania zmiennych.",
"update_installing": "Aktualizowanie…",
"update_success": "Zaktualizowano do v{{version}} — ponowne ładowanie…",
"update_failed": "Aktualizacja nie powiodła się"
},
"delete_favorite_modal": {
"title": "Usuń ulubione",
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/locales/pt-BR.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
"delete": "Excluir",
"clean": "Limpar",
"update_available": "↑ v{{version}} disponível — toque para baixar",
"no_data": "Uma conexão com a internet é necessária para carregar as variáveis."
"no_data": "Uma conexão com a internet é necessária para carregar as variáveis.",
"update_installing": "Atualizando…",
"update_success": "Atualizado para v{{version}} — recarregando…",
"update_failed": "Falha na atualização"
},
"delete_favorite_modal": {
"title": "Remover favorito",
Expand Down
5 changes: 4 additions & 1 deletion src/i18n/locales/pt-PT.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@
"delete": "Eliminar",
"clean": "Limpar",
"update_available": "↑ v{{version}} disponível — toque para transferir",
"no_data": "É necessária uma ligação à internet para carregar as variáveis."
"no_data": "É necessária uma ligação à internet para carregar as variáveis.",
"update_installing": "A atualizar…",
"update_success": "Atualizado para v{{version}} — a recarregar…",
"update_failed": "Falha na atualização"
},
"delete_favorite_modal": {
"title": "Remover favorito",
Expand Down
Loading
Loading