Skip to content

Commit d215ad3

Browse files
committed
auto steam path detection
1 parent 00547ec commit d215ad3

File tree

5 files changed

+261
-30
lines changed

5 files changed

+261
-30
lines changed

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,20 @@ $ npm run build:linux:local
3434
```
3535

3636
Remove :local to publish to Github.
37+
38+
## Game path detection
39+
40+
The launcher automatically detects your Balatro installation if you haven’t set a custom path.
41+
42+
How it works:
43+
- Windows: Reads the Steam install path from the registry (HKLM\SOFTWARE\WOW6432Node\Valve\Steam → InstallPath), parses steamapps/libraryfolders.vdf to enumerate libraries, then looks for steamapps/common/Balatro. Falls back to C:\Program Files (x86)\Steam if needed.
44+
- macOS: Looks under ~/Library/Application Support/Steam/steamapps/common/Balatro and validates the Balatro.app bundle.
45+
- Linux: Looks under ~/.local/share/Steam/steamapps/common/Balatro (Proton layout) and validates presence of Balatro.exe.
46+
47+
Validation rules:
48+
- Windows: Directory is considered valid if any of love.dll, lua51.dll, SDL2.dll, or Balatro.exe exists.
49+
- macOS: Checks for Balatro.app/Contents/Resources/Balatro.love.
50+
- Linux: Checks for Balatro.exe in the Balatro directory.
51+
52+
Custom path:
53+
- You can set a custom game directory in Settings. The path is validated before being saved. If you select the executable, the launcher will normalize it to the containing folder.

src/main/index.ts

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -115,9 +115,21 @@ app.whenReady().then(() => {
115115

116116
// Settings IPC handlers
117117
ipcMain.handle('settings:get-game-directory', () => settingsService.getGameDirectory())
118-
ipcMain.handle('settings:set-game-directory', (_, directory) => {
119-
settingsService.setGameDirectory(directory)
120-
return true
118+
ipcMain.handle('settings:set-game-directory', async (_, directory) => {
119+
try {
120+
const { isBalatroInstallDirValid, normalizeCustomPath } = await import(
121+
'./services/balatro-path.service'
122+
)
123+
const normalized = normalizeCustomPath(directory)
124+
if (await isBalatroInstallDirValid(normalized)) {
125+
settingsService.setGameDirectory(normalized)
126+
return true
127+
}
128+
return false
129+
} catch (e) {
130+
loggerService.error('Failed to validate and set game directory:', e)
131+
return false
132+
}
121133
})
122134
ipcMain.handle('settings:open-directory-dialog', async (event) => {
123135
const mainWindow = BrowserWindow.fromWebContents(event.sender)
@@ -131,17 +143,20 @@ app.whenReady().then(() => {
131143
return canceled ? null : filePaths[0]
132144
})
133145
ipcMain.handle('settings:get-default-game-directory', async () => {
146+
try {
147+
const { resolveBalatroPath } = await import('./services/balatro-path.service')
148+
// Try to find an install automatically
149+
const saved = settingsService.getGameDirectory()
150+
const detected = await resolveBalatroPath(saved)
151+
if (detected) return detected
152+
} catch (e) {
153+
loggerService.error('Auto-detect default game directory failed:', e)
154+
}
155+
156+
// Fallback to simple platform defaults if they exist
134157
const platform = process.platform
135158
const defaultPath = {
136-
win32: path.join(
137-
os.homedir(),
138-
'AppData',
139-
'Roaming',
140-
'Steam',
141-
'steamapps',
142-
'common',
143-
'Balatro'
144-
),
159+
win32: path.join('C:', 'Program Files (x86)', 'Steam', 'steamapps', 'common', 'Balatro'),
145160
darwin: path.join(
146161
os.homedir(),
147162
'Library',
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import os from 'node:os'
2+
import path from 'node:path'
3+
import fs from 'fs-extra'
4+
import { promisify } from 'node:util'
5+
import { exec } from 'node:child_process'
6+
import { loggerService } from './logger.service'
7+
8+
const execAsync = promisify(exec)
9+
10+
async function pathExists(p: string): Promise<boolean> {
11+
try {
12+
return await fs.pathExists(p)
13+
} catch {
14+
return false
15+
}
16+
}
17+
18+
function dedupCaseInsensitive(items: string[]): string[] {
19+
const seen = new Set<string>()
20+
const out: string[] = []
21+
for (const s of items) {
22+
const key = s.toLowerCase()
23+
if (!seen.has(key)) {
24+
seen.add(key)
25+
out.push(s)
26+
}
27+
}
28+
return out
29+
}
30+
31+
async function readText(p: string): Promise<string | null> {
32+
try {
33+
if (!(await pathExists(p))) return null
34+
return await fs.readFile(p, 'utf8')
35+
} catch {
36+
return null
37+
}
38+
}
39+
40+
function parseLibraryFoldersVdfToPaths(vdfContents: string): string[] {
41+
const paths: string[] = []
42+
const re = /"path"\s*"([^"]+)"/g
43+
let m: RegExpExecArray | null
44+
while ((m = re.exec(vdfContents))) {
45+
paths.push(m[1])
46+
}
47+
return paths
48+
}
49+
50+
async function getSteamInstallPathWindows(): Promise<string | null> {
51+
try {
52+
const { stdout } = await execAsync(
53+
'reg query "HKLM\\SOFTWARE\\WOW6432Node\\Valve\\Steam" /v InstallPath'
54+
)
55+
// Output typically contains a line like: InstallPath REG_SZ C:\\Program Files (x86)\\Steam
56+
const line = stdout
57+
.split(/\r?\n/)
58+
.map((l) => l.trim())
59+
.find((l) => l.toLowerCase().startsWith('installpath'))
60+
if (line) {
61+
const parts = line.split(/\s{2,}/)
62+
const value = parts[parts.length - 1]
63+
if (value) return value
64+
}
65+
} catch (e) {
66+
// ignore, will fallback
67+
loggerService.debug?.('Windows registry query for Steam failed, falling back to default', e)
68+
}
69+
return 'C:\\Program Files (x86)\\Steam'
70+
}
71+
72+
export async function getSteamLibraries(): Promise<string[]> {
73+
const platform = os.platform()
74+
75+
if (platform === 'win32') {
76+
const installPath = await getSteamInstallPathWindows()
77+
if (!installPath) return []
78+
79+
const libraryVdf = path.join(installPath, 'steamapps', 'libraryfolders.vdf')
80+
const contents = await readText(libraryVdf)
81+
if (!contents) return dedupCaseInsensitive([installPath])
82+
83+
const libs = parseLibraryFoldersVdfToPaths(contents)
84+
return dedupCaseInsensitive([installPath, ...libs])
85+
}
86+
87+
if (platform === 'darwin') {
88+
return [path.join(os.homedir(), 'Library', 'Application Support', 'Steam')]
89+
}
90+
91+
// linux
92+
return [path.join(os.homedir(), '.local', 'share', 'Steam')]
93+
}
94+
95+
export async function getBalatroCandidates(): Promise<string[]> {
96+
const libs = await getSteamLibraries()
97+
const candidates = libs.map((lib) => path.join(lib, 'steamapps', 'common', 'Balatro'))
98+
const existing: string[] = []
99+
for (const c of candidates) {
100+
if (await pathExists(c)) existing.push(c)
101+
}
102+
return existing
103+
}
104+
105+
export async function isBalatroInstallDirValid(dir: string): Promise<boolean> {
106+
const platform = os.platform()
107+
108+
if (platform === 'win32') {
109+
const dlls = ['love.dll', 'lua51.dll', 'SDL2.dll']
110+
for (const dll of dlls) {
111+
if (await pathExists(path.join(dir, dll))) return true
112+
}
113+
if (await pathExists(path.join(dir, 'Balatro.exe'))) return true
114+
return false
115+
}
116+
117+
if (platform === 'darwin') {
118+
return await pathExists(
119+
path.join(dir, 'Balatro.app', 'Contents', 'Resources', 'Balatro.love')
120+
)
121+
}
122+
123+
// linux (Proton layout)
124+
return await pathExists(path.join(dir, 'Balatro.exe'))
125+
}
126+
127+
export async function resolveBalatroPath(savedPath?: string | null): Promise<string | null> {
128+
// 1) Use saved path first if valid
129+
if (savedPath && (await isBalatroInstallDirValid(savedPath))) return savedPath
130+
131+
// 2) Discover candidates
132+
const candidates = await getBalatroCandidates()
133+
for (const c of candidates) {
134+
if (await isBalatroInstallDirValid(c)) {
135+
return c
136+
}
137+
}
138+
139+
return null
140+
}
141+
142+
export function normalizeCustomPath(userSelectedPath: string): string {
143+
// If a file like an exe is provided, normalize to its directory
144+
const lower = userSelectedPath.toLowerCase()
145+
if (lower.endsWith('.exe')) {
146+
return path.dirname(userSelectedPath)
147+
}
148+
return userSelectedPath
149+
}

src/main/services/mod-installation.service.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,26 @@ async function getGameDirectory() {
5858
return customDir
5959
}
6060

61-
// If no custom directory is set, check if the default Steam path exists
61+
// Attempt automatic detection via Steam libraries
62+
try {
63+
const { resolveBalatroPath } = await import('./balatro-path.service')
64+
const detected = await resolveBalatroPath(null)
65+
if (detected) {
66+
// Persist for future runs
67+
settingsService.setGameDirectory(detected)
68+
return detected
69+
}
70+
} catch (e) {
71+
loggerService.error('Automatic game path detection failed:', e)
72+
}
73+
74+
// Legacy fallback: check a common default Steam path for this platform
6275
const defaultPath = STEAM_GAME_DIR[platform]
6376
if (defaultPath && (await fs.pathExists(defaultPath))) {
6477
return defaultPath
6578
}
6679

67-
// If not found, we could implement more sophisticated detection in the future
68-
// For now, return null to indicate that the game directory couldn't be determined
80+
// Not found
6981
return null
7082
}
7183

src/renderer/src/components/onboarding-page.tsx

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useEffect } from 'react'
1+
import { useState, useEffect, useRef } from 'react'
22
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
33
import { gameDirectoryQueryOptions, defaultGameDirectoryQueryOptions } from '@renderer/queries'
44
import { settingsService } from '@renderer/servicies/settings.service'
@@ -11,9 +11,13 @@ export function OnboardingPage() {
1111
const queryClient = useQueryClient()
1212
const { data: gameDirectory, isLoading: isLoadingGameDirectory } =
1313
useQuery(gameDirectoryQueryOptions)
14-
const { data: defaultGameDirectory } = useQuery(defaultGameDirectoryQueryOptions)
14+
const { data: defaultGameDirectory, isLoading: isLoadingDefault } = useQuery(
15+
defaultGameDirectoryQueryOptions
16+
)
1517

1618
const [customDirectory, setCustomDirectory] = useState<string>('')
19+
const [isAutoDetecting, setIsAutoDetecting] = useState<boolean>(false)
20+
const autoTriedRef = useRef(false)
1721

1822
// Initialize the custom directory input when data is loaded
1923
useEffect(() => {
@@ -43,6 +47,34 @@ export function OnboardingPage() {
4347
}
4448
})
4549

50+
// Auto-detect on first load: if we don't already have a saved directory but a default
51+
// (auto-detected) directory is available, save it automatically and finish onboarding.
52+
useEffect(() => {
53+
if (autoTriedRef.current) return
54+
if (isLoadingGameDirectory || isLoadingDefault) return
55+
if (gameDirectory) return
56+
57+
if (defaultGameDirectory) {
58+
autoTriedRef.current = true
59+
setIsAutoDetecting(true)
60+
settingsService
61+
.setGameDirectory(defaultGameDirectory)
62+
.then(async (success) => {
63+
if (success) {
64+
await settingsService.setOnboardingCompleted(true)
65+
queryClient.invalidateQueries({ queryKey: ['game-directory'] })
66+
queryClient.invalidateQueries({ queryKey: ['onboarding-completed'] })
67+
toast.success('Detected your Balatro installation and finished onboarding')
68+
}
69+
})
70+
.catch((error) => {
71+
// Just log a toast; UI remains for manual selection
72+
toast.error(`Failed to apply detected directory automatically: ${error}`)
73+
})
74+
.finally(() => setIsAutoDetecting(false))
75+
}
76+
}, [gameDirectory, defaultGameDirectory, isLoadingGameDirectory, isLoadingDefault, queryClient])
77+
4678
const handleSave = () => {
4779
if (customDirectory) {
4880
updateGameDirectoryMutation.mutate(customDirectory)
@@ -70,6 +102,8 @@ export function OnboardingPage() {
70102
}
71103
}
72104

105+
const isBusy = isAutoDetecting || updateGameDirectoryMutation.isPending
106+
73107
return (
74108
<div className="flex flex-col">
75109
<div className="flex-1 flex items-center justify-center p-4">
@@ -79,7 +113,9 @@ export function OnboardingPage() {
79113
Welcome to Balatro Multiplayer Launcher!
80114
</h1>
81115
<p className="text-muted-foreground mt-2">
82-
To get started, please set your Balatro game directory.
116+
{isAutoDetecting
117+
? 'Detecting your Balatro installation...'
118+
: 'To get started, please set your Balatro game directory.'}
83119
</p>
84120
</div>
85121

@@ -95,18 +131,20 @@ export function OnboardingPage() {
95131
className="flex-1"
96132
readOnly
97133
/>
98-
<Button
99-
type="button"
100-
onClick={handleBrowse}
101-
variant="outline"
102-
>
134+
<Button type="button" onClick={handleBrowse} variant="outline" disabled={isBusy}>
103135
Browse
104136
</Button>
105137
</div>
106138
</div>
107139

108140
{defaultGameDirectory && (
109-
<Button variant="outline" size="sm" onClick={handleUseDefault} className="w-full">
141+
<Button
142+
variant="outline"
143+
size="sm"
144+
onClick={handleUseDefault}
145+
className="w-full"
146+
disabled={isBusy}
147+
>
110148
Use Default Steam Path
111149
</Button>
112150
)}
@@ -117,12 +155,12 @@ export function OnboardingPage() {
117155
: 'Default Steam path not detected'}
118156
</p>
119157

120-
<Button
121-
className="w-full mt-4"
122-
onClick={handleSave}
123-
disabled={updateGameDirectoryMutation.isPending}
124-
>
125-
{updateGameDirectoryMutation.isPending ? 'Saving...' : 'Continue'}
158+
<Button className="w-full mt-4" onClick={handleSave} disabled={isBusy}>
159+
{updateGameDirectoryMutation.isPending
160+
? 'Saving...'
161+
: isAutoDetecting
162+
? 'Detecting...'
163+
: 'Continue'}
126164
</Button>
127165
</div>
128166
</div>

0 commit comments

Comments
 (0)