Skip to content

Commit 3a63bc2

Browse files
committed
[Feat] Add code to look for vdf files that define compatibility tool locations instead of just assuming that <path>/proton exists. This fixes an issue with cachyos packaging of their native proton. Also adds a test for the new code.
1 parent 86babaf commit 3a63bc2

File tree

3 files changed

+443
-28
lines changed

3 files changed

+443
-28
lines changed

src/backend/__mocks__/utils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// Mock for backend/utils module
2+
export const getSteamLibraries = jest.fn(() => Promise.resolve([]))
3+
export const execAsync = jest.fn(() => Promise.resolve())
4+
5+
// Placeholder for any other exports that might be needed
6+
export const axiosClient = {}

src/backend/utils/__tests__/compatibility_layers.test.ts

Lines changed: 268 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,55 @@
1-
import {
2-
getDefaultWine,
3-
getWineExecs,
4-
getWineLibs
5-
} from '../compatibility_layers'
6-
import { mkdirSync } from 'graceful-fs'
1+
import { mkdirSync, writeFileSync } from 'graceful-fs'
72
import { dirname, join } from 'path'
83
import { tmpdir } from 'os'
94
import child_process from 'child_process'
105

11-
jest.mock('../../logger')
6+
// Mock modules BEFORE importing the module under test
7+
jest.mock('backend/logger', () => ({
8+
logError: jest.fn(),
9+
logInfo: jest.fn(),
10+
LogPrefix: {
11+
GlobalConfig: 'GlobalConfig'
12+
}
13+
}))
14+
15+
// Mock utils with explicit implementation
16+
jest.mock('backend/utils', () => ({
17+
getSteamLibraries: () => Promise.resolve([]),
18+
execAsync: () => Promise.resolve()
19+
}))
20+
21+
// Mock GlobalConfig with the settings needed for tests
22+
jest.mock('backend/config', () => ({
23+
GlobalConfig: {
24+
get: () => ({
25+
getSettings: () => ({
26+
showValveProton: false,
27+
customWinePaths: []
28+
})
29+
})
30+
}
31+
}))
32+
33+
import {
34+
getDefaultWine,
35+
getWineExecs,
36+
getWineLibs,
37+
getLinuxWineSet
38+
} from '../compatibility_layers'
39+
jest.mock('backend/constants/paths', () => {
40+
const pathModule = jest.requireActual<typeof import('path')>('path')
41+
const osModule = jest.requireActual<typeof import('os')>('os')
42+
const testToolsPath = pathModule.join(osModule.tmpdir(), 'heroic-test-tools')
43+
return {
44+
...jest.requireActual('backend/constants/paths'),
45+
toolsPath: testToolsPath,
46+
configPath: pathModule.join(testToolsPath, 'config.json'),
47+
userHome: osModule.tmpdir(),
48+
appFolder: testToolsPath,
49+
publicDir: pathModule.join(__dirname, '..', '..', '..', 'public'),
50+
fixAsarPath: (path: string) => path
51+
}
52+
})
1253

1354
describe('getDefaultWine', () => {
1455
test('return wine not found', () => {
@@ -99,3 +140,223 @@ describe('getWineExes', () => {
99140
})
100141
})
101142
})
143+
144+
describe('getLinuxWineSet - Proton detection', () => {
145+
const testToolsPath = join(tmpdir(), 'heroic-test-tools')
146+
const protonPath = join(testToolsPath, 'proton')
147+
148+
beforeEach(() => {
149+
// Clean up and create fresh test directories
150+
mkdirSync(join(testToolsPath, 'wine'), { recursive: true })
151+
mkdirSync(protonPath, { recursive: true })
152+
})
153+
154+
describe('VDF-based detection', () => {
155+
it('should detect proton from VDF file with relative install_path', async () => {
156+
const protonDir = join(protonPath, 'GE-Proton8-1')
157+
const protonBin = join(protonDir, 'proton')
158+
mkdirSync(protonDir, { recursive: true })
159+
writeFileSync(protonBin, '#!/bin/bash\necho "proton"')
160+
161+
// Create VDF file in the proton directory
162+
const vdfContent = `"compatibilitytools"
163+
{
164+
"compat_tools"
165+
{
166+
"GE-Proton8-1"
167+
{
168+
"display_name" "GE-Proton 8.1"
169+
"install_path" "."
170+
}
171+
}
172+
}`
173+
writeFileSync(join(protonDir, 'compatibilitytool.vdf'), vdfContent)
174+
175+
const result = await getLinuxWineSet()
176+
const protonInstalls = Array.from(result).filter(
177+
(w) => w.type === 'proton'
178+
)
179+
180+
const geProton = protonInstalls.find((p) => p.name === 'GE-Proton 8.1')
181+
expect(geProton).toBeDefined()
182+
expect(geProton?.bin).toBe(protonBin)
183+
})
184+
185+
it('should detect proton from VDF file with absolute install_path', async () => {
186+
const protonDir = join(protonPath, 'custom-proton')
187+
const actualProtonDir = join(tmpdir(), 'custom-install-location')
188+
const protonBin = join(actualProtonDir, 'proton')
189+
mkdirSync(protonDir, { recursive: true })
190+
mkdirSync(actualProtonDir, { recursive: true })
191+
writeFileSync(protonBin, '#!/bin/bash\necho "proton"')
192+
193+
const vdfContent = `"compatibilitytools"
194+
{
195+
"compat_tools"
196+
{
197+
"custom-proton"
198+
{
199+
"display_name" "Custom Proton"
200+
"install_path" "${actualProtonDir.replace(/\\/g, '/')}"
201+
}
202+
}
203+
}`
204+
writeFileSync(join(protonDir, 'compatibilitytool.vdf'), vdfContent)
205+
206+
const result = await getLinuxWineSet()
207+
const protonInstalls = Array.from(result).filter(
208+
(w) => w.type === 'proton'
209+
)
210+
211+
const customProton = protonInstalls.find(
212+
(p) => p.name === 'Custom Proton'
213+
)
214+
expect(customProton).toBeDefined()
215+
expect(customProton?.bin).toBe(protonBin)
216+
})
217+
218+
it('should skip VDF files without proton binary', async () => {
219+
const protonDir = join(protonPath, 'MissingBinary')
220+
mkdirSync(protonDir, { recursive: true })
221+
222+
const vdfContent = `"compatibilitytools"
223+
{
224+
"compat_tools"
225+
{
226+
"MissingBinary"
227+
{
228+
"display_name" "Missing Binary"
229+
"install_path" "."
230+
}
231+
}
232+
}`
233+
writeFileSync(join(protonDir, 'compatibilitytool.vdf'), vdfContent)
234+
235+
const result = await getLinuxWineSet()
236+
const protonInstalls = Array.from(result).filter(
237+
(w) => w.type === 'proton'
238+
)
239+
240+
const missingProton = protonInstalls.find(
241+
(p) => p.name === 'Missing Binary'
242+
)
243+
expect(missingProton).toBeUndefined()
244+
})
245+
246+
it('should skip UMU-Latest directories', async () => {
247+
const umuDir = join(protonPath, 'UMU-Latest-1234')
248+
const protonBin = join(umuDir, 'proton')
249+
mkdirSync(umuDir, { recursive: true })
250+
writeFileSync(protonBin, '#!/bin/bash\necho "proton"')
251+
252+
const vdfContent = `"compatibilitytools"
253+
{
254+
"compat_tools"
255+
{
256+
"UMU-Latest"
257+
{
258+
"display_name" "UMU Latest"
259+
"install_path" "."
260+
}
261+
}
262+
}`
263+
writeFileSync(join(umuDir, 'compatibilitytool.vdf'), vdfContent)
264+
265+
const result = await getLinuxWineSet()
266+
const protonInstalls = Array.from(result).filter(
267+
(w) => w.type === 'proton'
268+
)
269+
270+
const umuProton = protonInstalls.find((p) => p.name.includes('UMU'))
271+
expect(umuProton).toBeUndefined()
272+
})
273+
})
274+
275+
describe('Non-VDF fallback detection', () => {
276+
it('should detect proton from directory structure without VDF', async () => {
277+
const protonDir = join(protonPath, 'OldProton-5.0')
278+
const protonBin = join(protonDir, 'proton')
279+
mkdirSync(protonDir, { recursive: true })
280+
writeFileSync(protonBin, '#!/bin/bash\necho "proton"')
281+
282+
const result = await getLinuxWineSet()
283+
const protonInstalls = Array.from(result).filter(
284+
(w) => w.type === 'proton'
285+
)
286+
287+
const oldProton = protonInstalls.find((p) => p.name === 'OldProton-5.0')
288+
expect(oldProton).toBeDefined()
289+
expect(oldProton?.bin).toBe(protonBin)
290+
})
291+
292+
it('should not duplicate entries when both VDF and binary exist', async () => {
293+
const protonDir = join(protonPath, 'DualProton')
294+
const protonBin = join(protonDir, 'proton')
295+
mkdirSync(protonDir, { recursive: true })
296+
writeFileSync(protonBin, '#!/bin/bash\necho "proton"')
297+
298+
// Create VDF
299+
const vdfContent = `"compatibilitytools"
300+
{
301+
"compat_tools"
302+
{
303+
"DualProton"
304+
{
305+
"display_name" "Dual Proton"
306+
"install_path" "."
307+
}
308+
}
309+
}`
310+
writeFileSync(join(protonDir, 'compatibilitytool.vdf'), vdfContent)
311+
312+
const result = await getLinuxWineSet()
313+
const protonInstalls = Array.from(result).filter(
314+
(w) => w.type === 'proton'
315+
)
316+
317+
const dualProtonMatches = protonInstalls.filter(
318+
(p) => p.name === 'Dual Proton' || p.name === 'DualProton'
319+
)
320+
expect(dualProtonMatches.length).toBe(1)
321+
})
322+
323+
it('should skip UMU-Latest directories in fallback detection', async () => {
324+
const umuDir = join(protonPath, 'UMU-Latest-5678')
325+
const protonBin = join(umuDir, 'proton')
326+
mkdirSync(umuDir, { recursive: true })
327+
writeFileSync(protonBin, '#!/bin/bash\necho "proton"')
328+
329+
const result = await getLinuxWineSet()
330+
const protonInstalls = Array.from(result).filter(
331+
(w) => w.type === 'proton'
332+
)
333+
334+
const umuProton = protonInstalls.find((p) =>
335+
p.name.startsWith('UMU-Latest')
336+
)
337+
expect(umuProton).toBeUndefined()
338+
})
339+
340+
it('should detect multiple proton versions without VDF', async () => {
341+
const proton1Dir = join(protonPath, 'Proton-4.11')
342+
const proton2Dir = join(protonPath, 'Proton-5.0')
343+
344+
mkdirSync(proton1Dir, { recursive: true })
345+
mkdirSync(proton2Dir, { recursive: true })
346+
347+
writeFileSync(join(proton1Dir, 'proton'), '#!/bin/bash\necho "proton"')
348+
writeFileSync(join(proton2Dir, 'proton'), '#!/bin/bash\necho "proton"')
349+
350+
const result = await getLinuxWineSet()
351+
const protonInstalls = Array.from(result).filter(
352+
(w) => w.type === 'proton'
353+
)
354+
355+
const proton411 = protonInstalls.find((p) => p.name === 'Proton-4.11')
356+
const proton50 = protonInstalls.find((p) => p.name === 'Proton-5.0')
357+
358+
expect(proton411).toBeDefined()
359+
expect(proton50).toBeDefined()
360+
})
361+
})
362+
})

0 commit comments

Comments
 (0)