Skip to content
Merged
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
117 changes: 114 additions & 3 deletions py_modules/proton_launch/launch_option.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@

import decky

from .vdf import parse_text, serialize_text
from .steam import get_user_dirs
from .vdf import parse_text, serialize_text, read_binary, write_binary
from .steam import get_user_dirs, get_shortcuts_paths
from .profile import chown_to_user


Expand Down Expand Up @@ -65,7 +65,11 @@ def set_launch_option(app_id: int) -> bool:
return True

if current.strip():
apps[app_str]["LaunchOptions"] = f"{LAUNCH_OPTION} {current}"
if "%command%" in current:
# Insert wrapper just before %command% so existing options are preserved
apps[app_str]["LaunchOptions"] = current.replace("%command%", LAUNCH_OPTION, 1)
else:
apps[app_str]["LaunchOptions"] = f"{LAUNCH_OPTION} {current}"
else:
apps[app_str]["LaunchOptions"] = LAUNCH_OPTION

Expand Down Expand Up @@ -110,6 +114,113 @@ def remove_launch_option(app_id: int) -> bool:
return False


def set_launch_option_shortcut(app_id: int) -> bool:
"""Write ~/proton-launch %command% to shortcuts.vdf for this non-Steam app."""
for sc_path in get_shortcuts_paths():
try:
raw = sc_path.read_bytes()
nodes, _ = read_binary(raw, 0)
modified = False

new_top = []
for tag, key, children in nodes:
if tag == 0x00 and key.lower() == "shortcuts":
new_children = []
for etag, ekey, efields in children:
if etag != 0x00:
new_children.append((etag, ekey, efields))
continue
appid_val = None
for f in efields:
if f[0] == 0x02 and f[1].lower() == "appid":
appid_val = f[2] & 0xFFFFFFFF
if appid_val == app_id:
new_fields = []
found_lo = False
for f in efields:
if f[0] == 0x01 and f[1].lower() == "launchoptions":
found_lo = True
current = f[2]
if LAUNCH_OPTION in current:
new_fields.append(f)
elif current.strip():
if "%command%" in current:
new_val = current.replace("%command%", LAUNCH_OPTION, 1)
else:
new_val = f"{LAUNCH_OPTION} {current}"
new_fields.append((0x01, f[1], new_val))
else:
new_fields.append((0x01, f[1], LAUNCH_OPTION))
else:
new_fields.append(f)
if not found_lo:
new_fields.append((0x01, "LaunchOptions", LAUNCH_OPTION))
new_children.append((etag, ekey, new_fields))
modified = True
else:
new_children.append((etag, ekey, efields))
new_top.append((tag, key, new_children))
else:
new_top.append((tag, key, children))

if modified:
shutil.copy2(sc_path, sc_path.with_suffix(".vdf.bak"))
sc_path.write_bytes(write_binary(new_top))
chown_to_user(sc_path)
decky.logger.info(f"[launch_option] shortcut set for app {app_id} in {sc_path}")
return True
except Exception as e:
decky.logger.error(f"[launch_option] shortcut set error for {app_id} in {sc_path}: {e}\n{traceback.format_exc()}")
return False


def remove_launch_option_shortcut(app_id: int) -> bool:
"""Remove ~/proton-launch %command% from shortcuts.vdf for this non-Steam app."""
for sc_path in get_shortcuts_paths():
try:
raw = sc_path.read_bytes()
nodes, _ = read_binary(raw, 0)
modified = False

new_top = []
for tag, key, children in nodes:
if tag == 0x00 and key.lower() == "shortcuts":
new_children = []
for etag, ekey, efields in children:
if etag != 0x00:
new_children.append((etag, ekey, efields))
continue
appid_val = None
for f in efields:
if f[0] == 0x02 and f[1].lower() == "appid":
appid_val = f[2] & 0xFFFFFFFF
if appid_val == app_id:
new_fields = []
for f in efields:
if f[0] == 0x01 and f[1].lower() == "launchoptions":
new_val = f[2].replace(LAUNCH_OPTION, "").strip()
new_fields.append((0x01, f[1], new_val))
modified = True
else:
new_fields.append(f)
new_children.append((etag, ekey, new_fields))
else:
new_children.append((etag, ekey, efields))
new_top.append((tag, key, new_children))
else:
new_top.append((tag, key, children))

if modified:
shutil.copy2(sc_path, sc_path.with_suffix(".vdf.bak"))
sc_path.write_bytes(write_binary(new_top))
chown_to_user(sc_path)
decky.logger.info(f"[launch_option] shortcut removed for app {app_id} in {sc_path}")
return True
except Exception as e:
decky.logger.error(f"[launch_option] shortcut remove error for {app_id}: {e}\n{traceback.format_exc()}")
return False


def get_status(app_id: int) -> str:
"""Return the current LaunchOptions string for this app from localconfig.vdf."""
try:
Expand Down
113 changes: 112 additions & 1 deletion py_modules/proton_launch/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@
from .image import is_horizontal
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 .launch_option import (
LAUNCH_OPTION,
set_launch_option, remove_launch_option, get_status,
set_launch_option_shortcut, remove_launch_option_shortcut,
)


class Plugin:
Expand Down Expand Up @@ -175,6 +179,82 @@ async def delete_game_profile(self, app_id: int) -> bool:
decky.logger.error(f"[delete_game_profile] {app_id}: {e}")
return False

# ── Quick wrapper management (no profile required) ──────────────────────────

async def add_launch_option(self, app_id: int, is_shortcut: bool) -> Dict[str, Any]:
"""Add ~/proton-launch %command% directly to a game's launch options.
Returns {success: bool, needs_restart: bool}."""
try:
if is_shortcut:
ok = set_launch_option_shortcut(app_id)
else:
ok = set_launch_option(app_id)
return {"success": ok, "needs_restart": ok}
except Exception as e:
decky.logger.error(f"[add_launch_option] {app_id}: {e}")
return {"success": False, "needs_restart": False}

async def remove_launch_option_only(self, app_id: int, is_shortcut: bool) -> bool:
"""Remove ~/proton-launch %command% from a game's launch options (keeps profile)."""
try:
if is_shortcut:
return remove_launch_option_shortcut(app_id)
else:
return remove_launch_option(app_id)
except Exception as e:
decky.logger.error(f"[remove_launch_option_only] {app_id}: {e}")
return False

async def get_wrapper_app_ids(self) -> List[int]:
"""Return all app_ids that have ~/proton-launch %command% in launch options."""
try:
result: List[int] = []

for user_dir in get_user_dirs():
lc = user_dir / "config" / "localconfig.vdf"
if not lc.is_file():
continue
try:
data = parse_text(lc.read_text(encoding="utf-8", errors="replace"))
apps = (
data.get("UserLocalConfigStore", {})
.get("Software", {})
.get("Valve", {})
.get("Steam", {})
.get("apps", {})
)
for app_str, app_data in apps.items():
if app_str.isdigit() and LAUNCH_OPTION in app_data.get("LaunchOptions", ""):
result.append(int(app_str))
except Exception as e:
decky.logger.warning(f"[get_wrapper_app_ids] localconfig error {lc}: {e}")

for sc_path in get_shortcuts_paths():
try:
raw = sc_path.read_bytes()
nodes, _ = read_binary(raw, 0)
for tag, key, children in nodes:
if tag == 0x00 and key.lower() == "shortcuts":
for etag, _, efields in children:
if etag != 0x00:
continue
appid_val = None
lo = ""
for f in efields:
if f[0] == 0x02 and f[1].lower() == "appid":
appid_val = f[2] & 0xFFFFFFFF
elif f[0] == 0x01 and f[1].lower() == "launchoptions":
lo = f[2]
if appid_val is not None and LAUNCH_OPTION in lo:
result.append(appid_val)
except Exception as e:
decky.logger.warning(f"[get_wrapper_app_ids] shortcuts error {sc_path}: {e}")

return result
except Exception as e:
decky.logger.error(f"[get_wrapper_app_ids] {e}")
return []

async def get_configured_apps(self) -> List[int]:
try:
return [
Expand Down Expand Up @@ -302,6 +382,37 @@ async def get_last_launched_appid(self) -> int:
async def get_launch_option_status(self, app_id: int) -> str:
return get_status(app_id)

async def get_wrapper_status(self, app_id: int, is_shortcut: bool) -> bool:
"""Return True if ~/proton-launch %command% is in this game's launch options."""
try:
if is_shortcut:
for sc_path in get_shortcuts_paths():
try:
raw = sc_path.read_bytes()
nodes, _ = read_binary(raw, 0)
for tag, key, children in nodes:
if tag == 0x00 and key.lower() == "shortcuts":
for etag, _, efields in children:
if etag != 0x00:
continue
appid_val = None
lo = ""
for f in efields:
if f[0] == 0x02 and f[1].lower() == "appid":
appid_val = f[2] & 0xFFFFFFFF
elif f[0] == 0x01 and f[1].lower() == "launchoptions":
lo = f[2]
if appid_val == app_id:
return LAUNCH_OPTION in lo
except Exception as e:
decky.logger.warning(f"[get_wrapper_status] shortcuts error {sc_path}: {e}")
return False
else:
return LAUNCH_OPTION in get_status(app_id)
except Exception as e:
decky.logger.error(f"[get_wrapper_status] {app_id}: {e}")
return False

# ── Running game ────────────────────────────────────────────────────────────

async def get_running_game(self) -> Dict[str, Any]:
Expand Down
19 changes: 19 additions & 0 deletions py_modules/proton_launch/vdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,25 @@ def serialize_text(data: Any, indent: int = 0) -> str:
return "\n".join(lines)


def write_binary(nodes: list) -> bytes:
"""Serialize a list of binary VDF nodes back to bytes (mirrors read_binary)."""
result = bytearray()
for item in nodes:
tag, key, value = item[0], item[1], item[2]
result.append(tag)
result.extend(key.encode("utf-8") + b"\x00")
if tag == 0x00: # dict/section — value is a list of child nodes
result.extend(write_binary(value))
elif tag == 0x01: # string
result.extend(str(value).encode("utf-8") + b"\x00")
elif tag == 0x02: # int32
result.extend(struct.pack("<i", int(value)))
elif tag == 0x07: # uint64
result.extend(struct.pack("<Q", int(value)))
result.append(0x08) # end marker
return bytes(result)


def read_binary(data: bytes, pos: int) -> Tuple[List, int]:
nodes = []
length = len(data)
Expand Down
26 changes: 14 additions & 12 deletions src/components/ActionButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,21 @@ export const ActionButton: React.FC<ActionButtonProps> = ({

return (
<>
<style>{`
.dialog-button.danger {
background-color: #ef4444 !important;
color: #fff;
}
.dialog-button.danger:focus,
.dialog-button.danger:hover {
color: #ef4444 !important;
background-color: #fff !important;
}
`}</style>
{variant === "danger" && (
<style>{`
.dpl-action-danger {
background-color: #ef4444 !important;
color: #fff !important;
}
.dpl-action-danger:focus,
.dpl-action-danger:hover {
background-color: #fff !important;
color: #ef4444 !important;
}
`}</style>
)}
<DialogButton
className={`dialog-button ${variant === "danger" ? "danger" : "primary"}`}
className={variant === "danger" ? "dpl-action-danger" : undefined}
style={{
...style,
display: "inline-flex",
Expand Down
31 changes: 9 additions & 22 deletions src/components/ButtonDeleteFavoriteModal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from "react";
import { DialogButton, Focusable, ModalRoot } from "@decky/ui";
import { useFavorites } from "../context/FavoritesContext";
import { ActionButton } from "./ActionButton";
import { ConfirmDeleteModal } from "./ConfirmDeleteModal";
import { useTranslation } from "react-i18next";
import { FiTrash } from "react-icons/fi";
import { openDeleteFavoriteModal } from "../utils/modals";
Expand All @@ -16,29 +16,16 @@ export const DeleteFavoriteModalContent: React.FC<
> = ({ title, onClose }) => {
const { removeFavorite } = useFavorites();
const { t } = useTranslation("delete_favorite_modal");
const { t: tCommon } = useTranslation();
const { t: tCommon } = useTranslation("common");

return (
<ModalRoot>
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
<div style={{ fontWeight: 600 }}>{t("title")}</div>
<div>{t("description", { favorite_name: title })}</div>
<Focusable
style={{ display: "flex", justifyContent: "flex-end", gap: "8px" }}
flow-children="horizontal"
>
<DialogButton onClick={onClose}>{tCommon("cancel")}</DialogButton>
<DialogButton
onClick={() => {
removeFavorite(title);
onClose();
}}
>
{tCommon("delete")}
</DialogButton>
</Focusable>
</div>
</ModalRoot>
<ConfirmDeleteModal
title={t("title")}
description={t("description", { favorite_name: title })}
confirmLabel={tCommon("delete")}
onConfirm={() => removeFavorite(title)}
onClose={onClose}
/>
);
};

Expand Down
Loading
Loading