Skip to content

Commit b4d545a

Browse files
authored
feat: add non-Steam games section (#48)
1 parent c38bc23 commit b4d545a

4 files changed

Lines changed: 886 additions & 1 deletion

File tree

main.py

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1718,6 +1718,312 @@ async def list_installed_games(self) -> dict:
17181718
decky.logger.error(str(e))
17191719
return {'status': 'error', 'message': str(e)}
17201720

1721+
def _parse_shortcuts_vdf(self, filepath: str) -> list:
1722+
"""Parse Steam's binary shortcuts.vdf file and return a list of shortcut entries.
1723+
1724+
Binary VDF format:
1725+
- 0x00 = nested object start (key is null-terminated string)
1726+
- 0x01 = string value (key and value are null-terminated)
1727+
- 0x02 = int32 value (key is null-terminated, value is 4 bytes LE)
1728+
- 0x08 = end of object
1729+
"""
1730+
entries = []
1731+
try:
1732+
with open(filepath, 'rb') as f:
1733+
data = f.read()
1734+
1735+
pos = 0
1736+
size = len(data)
1737+
1738+
def read_string(p):
1739+
end = data.index(b'\x00', p)
1740+
return data[p:end].decode('utf-8', errors='replace'), end + 1
1741+
1742+
def parse_object(p):
1743+
obj = {}
1744+
while p < size:
1745+
type_byte = data[p]
1746+
p += 1
1747+
1748+
if type_byte == 0x08: # End of object
1749+
break
1750+
1751+
# Read key name
1752+
key, p = read_string(p)
1753+
1754+
if type_byte == 0x00: # Nested object
1755+
value, p = parse_object(p)
1756+
obj[key] = value
1757+
elif type_byte == 0x01: # String value
1758+
value, p = read_string(p)
1759+
obj[key] = value
1760+
elif type_byte == 0x02: # Int32 value
1761+
value = int.from_bytes(data[p : p + 4], byteorder='little', signed=True)
1762+
p += 4
1763+
obj[key] = value
1764+
else:
1765+
# Unknown type, skip
1766+
break
1767+
1768+
return obj, p
1769+
1770+
# Skip root object header: 0x00 + "shortcuts" + 0x00
1771+
if data[pos] == 0x00:
1772+
pos += 1
1773+
_, pos = read_string(pos)
1774+
1775+
# Parse each shortcut entry
1776+
while pos < size:
1777+
type_byte = data[pos]
1778+
if type_byte == 0x08: # End of root object
1779+
break
1780+
if type_byte == 0x00:
1781+
pos += 1
1782+
_, pos = read_string(pos) # Skip entry index key (e.g. "0", "1")
1783+
entry, pos = parse_object(pos)
1784+
entries.append(entry)
1785+
else:
1786+
break
1787+
1788+
except Exception as e:
1789+
decky.logger.error(f'Error parsing shortcuts.vdf at {filepath}: {e!s}')
1790+
1791+
return entries
1792+
1793+
async def list_non_steam_games(self) -> dict:
1794+
"""List non-Steam game shortcuts added via 'Add a Non-Steam Game' in Steam."""
1795+
try:
1796+
steam_root = Path(decky.HOME) / '.steam' / 'steam'
1797+
userdata_path = steam_root / 'userdata'
1798+
1799+
if not userdata_path.exists():
1800+
return {'status': 'error', 'message': 'Steam userdata directory not found'}
1801+
1802+
games = []
1803+
seen_ids = set()
1804+
1805+
# Iterate all user profiles
1806+
for user_dir in userdata_path.iterdir():
1807+
if not user_dir.is_dir():
1808+
continue
1809+
1810+
shortcuts_file = user_dir / 'config' / 'shortcuts.vdf'
1811+
if not shortcuts_file.exists():
1812+
continue
1813+
1814+
entries = self._parse_shortcuts_vdf(str(shortcuts_file))
1815+
for entry in entries:
1816+
app_name = entry.get('appname', entry.get('AppName', ''))
1817+
exe = entry.get('exe', entry.get('Exe', ''))
1818+
start_dir = entry.get('StartDir', '')
1819+
app_id = entry.get('appid', entry.get('AppId', 0))
1820+
1821+
if not app_name or not exe:
1822+
continue
1823+
1824+
# Convert signed appid to unsigned for deduplication
1825+
unsigned_id = app_id & 0xFFFFFFFF if isinstance(app_id, int) else app_id
1826+
1827+
if unsigned_id in seen_ids:
1828+
continue
1829+
seen_ids.add(unsigned_id)
1830+
1831+
# Strip surrounding quotes from exe and start_dir
1832+
exe = exe.strip('"')
1833+
start_dir = start_dir.strip('"')
1834+
1835+
# Skip non-exe shortcuts (scripts, flatpaks, emulators, launchers)
1836+
if not exe.lower().endswith('.exe'):
1837+
continue
1838+
1839+
games.append(
1840+
{
1841+
'name': app_name,
1842+
'exe': exe,
1843+
'start_dir': start_dir,
1844+
'appid': str(unsigned_id),
1845+
}
1846+
)
1847+
1848+
# Sort alphabetically
1849+
games.sort(key=lambda g: g['name'].lower())
1850+
1851+
return {'status': 'success', 'games': games}
1852+
1853+
except Exception as e:
1854+
decky.logger.error(f'Error listing non-Steam games: {e!s}')
1855+
return {'status': 'error', 'message': str(e)}
1856+
1857+
async def find_non_steam_game_executable_path(
1858+
self, exe_path: str, start_dir: str, game_name: str
1859+
) -> dict:
1860+
"""Find executable paths for a non-Steam game shortcut.
1861+
1862+
If the shortcut's exe points to a .exe file, scan its directory for alternatives.
1863+
"""
1864+
try:
1865+
decky.logger.info(
1866+
f'Finding executable for non-Steam game: {game_name} (exe={exe_path}, start_dir={start_dir})'
1867+
)
1868+
1869+
# Check cache first
1870+
cache_key = f'nonsteam_{exe_path}_{game_name}'
1871+
if cache_key in self.executable_cache:
1872+
cached_result = self.executable_cache[cache_key]
1873+
if time.time() - cached_result.get('timestamp', 0) < 3600:
1874+
decky.logger.info(f'Using cached result for {game_name}')
1875+
return cached_result
1876+
1877+
# Determine the search directory
1878+
search_dir = None
1879+
1880+
if os.path.exists(exe_path):
1881+
# The shortcut directly points to a .exe - use its directory
1882+
search_dir = os.path.dirname(exe_path)
1883+
elif start_dir and os.path.isdir(start_dir):
1884+
search_dir = start_dir
1885+
1886+
if not search_dir or not os.path.isdir(search_dir):
1887+
# Can't find a valid directory to scan
1888+
if os.path.exists(exe_path):
1889+
# At least we have the exe itself
1890+
result = {
1891+
'status': 'success',
1892+
'non_steam_detection_result': {
1893+
'status': 'success',
1894+
'method': 'shortcut_exe',
1895+
'executable_path': exe_path,
1896+
'directory_path': os.path.dirname(exe_path),
1897+
'filename': os.path.basename(exe_path),
1898+
'all_executables': [
1899+
{
1900+
'path': exe_path,
1901+
'directory_path': os.path.dirname(exe_path),
1902+
'filename': os.path.basename(exe_path),
1903+
'relative_path': os.path.basename(exe_path),
1904+
'size': os.path.getsize(exe_path),
1905+
'size_mb': round(os.path.getsize(exe_path) / (1024 * 1024), 1),
1906+
'score': 100,
1907+
}
1908+
],
1909+
'confidence': 'high',
1910+
},
1911+
'recommended_method': 'shortcut_exe',
1912+
'timestamp': time.time(),
1913+
}
1914+
self.executable_cache[cache_key] = result
1915+
return result
1916+
1917+
return {
1918+
'status': 'error',
1919+
'method': 'non_steam_detection',
1920+
'message': f'Could not find a valid game directory to scan. Exe: {exe_path}, StartDir: {start_dir}',
1921+
}
1922+
1923+
# Walk the directory tree and find all .exe files
1924+
all_executables = []
1925+
decky.logger.info(f'Scanning directory: {search_dir}')
1926+
1927+
for root, _dirs, files in os.walk(search_dir):
1928+
for file in files:
1929+
if file.lower().endswith('.exe'):
1930+
full_path = os.path.join(root, file)
1931+
try:
1932+
file_size = os.path.getsize(full_path)
1933+
rel_path = os.path.relpath(full_path, search_dir)
1934+
all_executables.append(
1935+
{
1936+
'path': full_path,
1937+
'directory_path': os.path.dirname(full_path),
1938+
'relative_path': rel_path,
1939+
'filename': file,
1940+
'size': file_size,
1941+
'size_mb': round(file_size / (1024 * 1024), 1),
1942+
}
1943+
)
1944+
except Exception as e:
1945+
decky.logger.warning(f'Error getting size for {full_path}: {e!s}')
1946+
1947+
if not all_executables:
1948+
return {
1949+
'status': 'error',
1950+
'method': 'non_steam_detection',
1951+
'message': f'No .exe files found in {search_dir}',
1952+
}
1953+
1954+
decky.logger.info(f'Found {len(all_executables)} executables')
1955+
1956+
# Score executables using the Heroic scorer (it's more generic than Steam's)
1957+
scored_executables = []
1958+
for exe_info in all_executables:
1959+
score = score_heroic_executable(exe_info, game_name, search_dir, decky.logger)
1960+
1961+
# Bonus if this exe matches the shortcut's exe path
1962+
if os.path.normpath(exe_info['path']) == os.path.normpath(exe_path):
1963+
score += 50
1964+
decky.logger.info(
1965+
f'Bonus +50 for matching shortcut exe: {exe_info["filename"]}'
1966+
)
1967+
1968+
if score > 0:
1969+
scored_executables.append({**exe_info, 'score': score})
1970+
1971+
if not scored_executables:
1972+
# Fallback: include all with any score
1973+
for exe_info in all_executables:
1974+
score = score_heroic_executable(exe_info, game_name, search_dir, decky.logger)
1975+
if os.path.normpath(exe_info['path']) == os.path.normpath(exe_path):
1976+
score += 50
1977+
scored_executables.append({**exe_info, 'score': score})
1978+
1979+
scored_executables.sort(key=lambda x: x['score'], reverse=True)
1980+
top_executables = scored_executables[:5]
1981+
best_executable = top_executables[0]
1982+
1983+
decky.logger.info(f'Top executables for {game_name}:')
1984+
for i, exe in enumerate(top_executables):
1985+
decky.logger.info(
1986+
f' {i + 1}. {exe["filename"]} (score: {exe["score"]}) at {exe["relative_path"]}'
1987+
)
1988+
1989+
result = {
1990+
'status': 'success',
1991+
'non_steam_detection_result': {
1992+
'status': 'success',
1993+
'method': 'non_steam_detection',
1994+
'executable_path': best_executable['path'],
1995+
'directory_path': best_executable['directory_path'],
1996+
'filename': best_executable['filename'],
1997+
'all_executables': top_executables,
1998+
'confidence': 'high' if best_executable['score'] > 70 else 'medium',
1999+
},
2000+
'recommended_method': 'non_steam_detection',
2001+
'timestamp': time.time(),
2002+
}
2003+
2004+
self.executable_cache[cache_key] = result
2005+
return result
2006+
2007+
except Exception as e:
2008+
decky.logger.error(f'Non-Steam executable detection error: {e!s}')
2009+
return {'status': 'error', 'method': 'non_steam_detection', 'message': str(e)}
2010+
2011+
async def install_reshade_for_non_steam_game(
2012+
self, game_path: str, dll_override: str = 'dxgi', selected_executable_path: str = ''
2013+
) -> dict:
2014+
"""Install ReShade for a non-Steam game. Delegates to the Heroic installer since the logic is identical."""
2015+
decky.logger.info(
2016+
f'Installing ReShade for non-Steam game at: {game_path} (exe: {selected_executable_path})'
2017+
)
2018+
return await self.install_reshade_for_heroic_game(
2019+
game_path, dll_override, selected_executable_path
2020+
)
2021+
2022+
async def uninstall_reshade_for_non_steam_game(self, game_path: str) -> dict:
2023+
"""Uninstall ReShade from a non-Steam game. Delegates to the Heroic uninstaller."""
2024+
decky.logger.info(f'Uninstalling ReShade from non-Steam game at: {game_path}')
2025+
return await self.uninstall_reshade_for_heroic_game(game_path)
2026+
17212027
async def find_heroic_games(self) -> dict:
17222028
"""Find games installed through Heroic Launcher using the config file"""
17232029
try:

src/HeroicGamesSection.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,7 @@ const HeroicGamesSection = () => {
563563
};
564564

565565
return (
566-
<PanelSection title="Heroic Games ReShade">
566+
<PanelSection title="Heroic Games">
567567
{loading ? (
568568
<PanelSectionRow>
569569
<div>Loading Heroic games...</div>

0 commit comments

Comments
 (0)