From 06bfde21958fb1fcfea9aa710c81fdb7eb022616 Mon Sep 17 00:00:00 2001 From: Arkazzae Date: Fri, 23 Oct 2026 12:48:12 +0200 Subject: [PATCH] Add experimental Lutris and Bottles support --- .SRCINFO | 6 +- CHANGELOG.md | 9 + PKGBUILD | 6 +- README.md | 29 + patches/0039-linux-lutris-store.patch | 946 +++++++++++++++++++++++++ patches/0040-linux-bottles-store.patch | 801 +++++++++++++++++++++ 6 files changed, 1795 insertions(+), 2 deletions(-) create mode 100644 patches/0039-linux-lutris-store.patch create mode 100644 patches/0040-linux-bottles-store.patch diff --git a/.SRCINFO b/.SRCINFO index 5cc1cf8..f9083ed 100644 --- a/.SRCINFO +++ b/.SRCINFO @@ -1,7 +1,7 @@ pkgbase = vortex-linux pkgdesc = Community build of Vortex with generic Linux compatibility patches pkgver = 2.9.0 - pkgrel = 3 + pkgrel = 4 url = https://github.com/Arkazzae/VortexLinux install = vortex.install arch = x86_64 @@ -95,6 +95,8 @@ pkgbase = vortex-linux source = 0036-linux-appimage-nxm-links.patch source = 0037-linux-case-consistent-folders.patch source = 0038-linux-default-wine-prefix.patch + source = 0039-linux-lutris-store.patch + source = 0040-linux-bottles-store.patch source = vortex.sh source = vortex.desktop source = README.md @@ -148,6 +150,8 @@ pkgbase = vortex-linux sha512sums = SKIP sha512sums = SKIP sha512sums = SKIP + sha512sums = SKIP + sha512sums = SKIP sha512sums = 935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2 sha512sums = e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632 sha512sums = 2161dfa1cf027cdc074de7195b5f206b17ebd829ae415b9e7c9ee5f06d3952b6583030022dbe0d6e9221b5c577c411d7cd5322241f6d2299d9c886641215699b diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb5666..db304bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,15 @@ publishes a new Vortex version, taking any pending notes with it. ## Unreleased +- Experimental: games installed through Lutris' Wine runner are recognised. + Point Vortex at the game folder and it deploys into the game's Lutris prefix, + starts the game through Lutris and runs modding tools with the game's Wine + build. GOG and Epic games from Lutris' services are also found when a game + extension looks them up by store ID. +- Experimental: games installed in a Bottles bottle are recognised the same + way. A game folder inside a bottle, or one holding a program added to a + bottle, uses that bottle as its prefix, starts through `bottles-cli` and runs + tools with the bottle's runner. - The 2.6.2 recipe carries the Linux fixes from 2.8.2-3 through 2.9.0-3: extension config paths, deployment preflight and target logging, extensions behind a symlinked home, differently cased game paths, backslash archive diff --git a/PKGBUILD b/PKGBUILD index 82b06a2..9548a9a 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -2,7 +2,7 @@ pkgname=vortex-linux pkgver=2.9.0 -pkgrel=3 +pkgrel=4 pkgdesc="Community build of Vortex with generic Linux compatibility patches" arch=('x86_64') url="https://github.com/Arkazzae/VortexLinux" @@ -139,6 +139,8 @@ _patches=( '0036-linux-appimage-nxm-links.patch' '0037-linux-case-consistent-folders.patch' '0038-linux-default-wine-prefix.patch' + '0039-linux-lutris-store.patch' + '0040-linux-bottles-store.patch' ) source=( @@ -213,6 +215,8 @@ sha512sums=( 'SKIP' 'SKIP' 'SKIP' + 'SKIP' + 'SKIP' # Keep integrity checks for binaries downloaded outside this repository. '935db5c6cee19f2c016e67168bfae7b491044735de76c673abb3b125dd325fd5e779d7efe12ba80178d46689ae70a25e558a3fa846417d44c5f4ca256e7f4bf2' 'e3f31d298a2b674b54c7fc89fb3f06d9645fc5879a54f2ebf2ea20e9ee7ae55f1bfe3284c1f90a591d6be2d6bcd251790ddc27771d65303e7a6a56d331df4632' diff --git a/README.md b/README.md index 3e596a1..0187491 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This repo does the other thing: it patches Vortex to actually understand Linux, - Maps `Documents` / `AppData` into the right prefix, so mods and saves land where the game actually looks - Discovers Epic, GOG and Amazon games in native and Flatpak Heroic installations - Launches games through Steam or Heroic +- Experimental: recognises Windows games installed through Lutris or Bottles, deploys into their prefix and launches them through the launcher - Runs Windows modding tools in the game’s Wine/Proton prefix, including calls made by extensions - Reads and writes Windows INI files while preserving encoding, comments and hardlinks - Builds Bethesda plugin management with native Linux LOOT @@ -22,6 +23,17 @@ This repo does the other thing: it patches Vortex to actually understand Linux, These are general Linux fixes, not per-game hacks. Game-specific Vortex extensions can still carry their own Windows-only assumptions, so some games may still need fixes. +### What runs where + +| Part | Runs as | +|---|---| +| Vortex itself: UI, downloads, installers, deployment | native Linux process | +| Game discovery and launch through Steam, Heroic, Lutris, Bottles | native, through each launcher | +| Mod deployment, INI edits, LOOT sorting, FOMOD installers | native | +| Windows-only modding tools such as xEdit or BodySlide | the game's own Wine or Proton prefix | +| Windows .NET runtimes those tools need | bundled, loaded through Wine | +| Vortex's few Windows-only helpers, and tools run with no game selected | a separate Wine prefix under `~/.local/share/vortex-linux` | + ## Upstream is catching up 🎉 Vortex itself is slowly becoming more Linux-friendly. Since 2.7.1, the extension API's `util.epicGamesLauncher` is a shim over `GameStoreHelper` instead of `undefined` everywhere except Windows, so our Epic API fallback patch is gone 🎊. @@ -93,6 +105,23 @@ If Heroic is an AppImage outside `PATH`, set `HEROIC_BINARY` to its executable. Launch it once before scanning for games. Linux-native Heroic games keep their native configuration paths. +**Lutris and Bottles (experimental).** Games that use Lutris' Wine runner work +too. If Vortex doesn't find the game by itself, set its folder manually, as it +appears inside the Lutris prefix (also when that folder is a symlink to another +disk). Vortex recognises that it belongs to Lutris, uses the game's Lutris +prefix, starts it through Lutris and runs tools with the Wine version selected +for it in Lutris. Tools need a Wine build for now; games set to Proton in Lutris +can be deployed to and launched, but not run tools. Set `LUTRIS_BINARY` if +`lutris` isn't on `PATH`. + +Bottles works the same way: set the game folder inside the bottle, or the folder +of a program you added to a bottle. Vortex uses the bottle as the prefix, starts +a program added in Bottles through `bottles-cli`, and runs tools with the +bottle's runner. Bottles keeps bottles by name, so rename bottles that share a +name. Flatpak Bottles only sees folders its sandbox allows. Set +`BOTTLES_CLI_BINARY` if `bottles-cli` isn't on `PATH`. Both are new and lightly +tested; please report what breaks. + The separate prefix for Vortex’s own helper processes still defaults to `${XDG_DATA_HOME:-$HOME/.local/share}/vortex-linux/wineprefix`. diff --git a/patches/0039-linux-lutris-store.patch b/patches/0039-linux-lutris-store.patch new file mode 100644 index 0000000..b2ea401 --- /dev/null +++ b/patches/0039-linux-lutris-store.patch @@ -0,0 +1,946 @@ +diff --git a/src/renderer/src/extensions/gamemode_management/GameModeManager.ts b/src/renderer/src/extensions/gamemode_management/GameModeManager.ts +--- a/src/renderer/src/extensions/gamemode_management/GameModeManager.ts ++++ b/src/renderer/src/extensions/gamemode_management/GameModeManager.ts +@@ -27,6 +27,7 @@ import { ProcessCanceled, SetupError, UserCanceled } from "../../util/CustomErro + import EpicGamesLauncher from "../../util/EpicGamesLauncher"; + import * as fs from "../../util/fs"; + import { HeroicStore } from "../../util/Heroic"; ++import { LutrisStore } from "../../util/Lutris"; + import { activeProfile, discoveryByGame } from "../../util/selectors"; + import Steam from "../../util/Steam"; + import { normalizeStoreQuery } from "../../util/storeQuery"; +@@ -83,7 +84,9 @@ class GameModeManager { + OriginLauncher.create(), + UPlayLauncher.create(), + XboxLauncher.create(api), +- ...(process.platform === "linux" ? [new HeroicStore("gog"), new HeroicStore("amazon")] : []), ++ ...(process.platform === "linux" ++ ? [new HeroicStore("gog"), new HeroicStore("amazon"), new LutrisStore()] ++ : []), + ].filter(Boolean); + this.mActiveSearch = null; + this.mQuickDiscoveryAbort = null; +diff --git a/src/renderer/src/util/Lutris.test.ts b/src/renderer/src/util/Lutris.test.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/util/Lutris.test.ts +@@ -0,0 +1,400 @@ ++import * as fs from "node:fs"; ++import * as os from "node:os"; ++import * as path from "node:path"; ++import { DatabaseSync } from "node:sqlite"; ++ ++import { dump } from "js-yaml"; ++import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; ++ ++import { resetCompatibilityPathCache, selectedGamePrefix } from "./compatibilityPaths"; ++import { resolveGameTool } from "./linux/gameTools"; ++import { ++ findLutrisLaunchGame, ++ lutrisLaunchCommand, ++ lutrisPrefix, ++ lutrisRoots, ++ readLutrisGames, ++} from "./linux/lutris"; ++import { LutrisStore } from "./Lutris"; ++ ++vi.mock("node:os", async (original) => ({ ...(await original()), homedir: vi.fn() })); ++ ++interface Row { ++ id: number; ++ name: string; ++ slug: string; ++ runner?: string; ++ installed?: number; ++ directory?: string; ++ configpath?: string; ++ service?: string; ++ service_id?: string; ++} ++ ++describe.runIf(process.platform === "linux")("Lutris integration", () => { ++ let home: string; ++ let data: string; ++ let config: string; ++ let games: string; ++ function database(rows: Row[], dataPath = data): void { ++ fs.mkdirSync(dataPath, { recursive: true }); ++ const db = new DatabaseSync(path.join(dataPath, "pga.db")); ++ db.exec( ++ "CREATE TABLE games (id INTEGER PRIMARY KEY, name TEXT, slug TEXT, runner TEXT, " + ++ "installed INTEGER, directory TEXT, configpath TEXT, service TEXT, service_id TEXT)", ++ ); ++ const insert = db.prepare( ++ "INSERT INTO games (id, name, slug, runner, installed, directory, configpath, service, service_id) " + ++ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ++ ); ++ for (const row of rows) ++ insert.run( ++ row.id, ++ row.name, ++ row.slug, ++ row.runner ?? "wine", ++ row.installed ?? 1, ++ row.directory ?? null, ++ row.configpath ?? null, ++ row.service ?? null, ++ row.service_id ?? null, ++ ); ++ db.close(); ++ } ++ function yaml(file: string, value: unknown): void { ++ fs.mkdirSync(path.dirname(file), { recursive: true }); ++ fs.writeFileSync(file, typeof value === "string" ? value : dump(value)); ++ } ++ function prefixAt(location: string): string { ++ fs.mkdirSync(path.join(location, "drive_c/users"), { recursive: true }); ++ return location; ++ } ++ function program(name: string): string { ++ const binary = path.join(home, "bin", name); ++ fs.mkdirSync(path.dirname(binary), { recursive: true }); ++ fs.writeFileSync(binary, "#!/bin/sh\n", { mode: 0o755 }); ++ return binary; ++ } ++ function wineBuild(version: string, root = data): string { ++ const binary = path.join(root, "runners/wine", version, "bin/wine"); ++ fs.mkdirSync(path.dirname(binary), { recursive: true }); ++ fs.writeFileSync(binary, "#!/bin/sh\n", { mode: 0o755 }); ++ return binary; ++ } ++ /** A GOG game installed by Lutris inside its own prefix. */ ++ function gogGame(id = 3, slug = "example") { ++ const prefix = prefixAt(path.join(home, "Games", slug)); ++ const gameRoot = path.join(prefix, "drive_c/GOG Games/Example Game"); ++ fs.mkdirSync(path.join(gameRoot, "bin/x64"), { recursive: true }); ++ fs.writeFileSync(path.join(gameRoot, "bin/x64/Example.exe"), ""); ++ fs.writeFileSync(path.join(gameRoot, "goggame-1207658691.info"), "{}"); ++ database([ ++ { ++ id, ++ name: "Example Game", ++ slug, ++ directory: prefix, ++ configpath: `${slug}-1700000000`, ++ service: "gog", ++ service_id: "1207658691", ++ }, ++ ]); ++ yaml(path.join(games, `${slug}-1700000000.yml`), { ++ game: { exe: "drive_c/GOG Games/Example Game/bin/x64/Example.exe", prefix }, ++ system: { env: { DXVK_HUD: "compiler", GAME_VALUE: 1 } }, ++ wine: { version: "wine-ge-8-26" }, ++ }); ++ return { prefix, gameRoot }; ++ } ++ ++ beforeEach(() => { ++ home = fs.mkdtempSync(path.join(os.tmpdir(), "vortex-lutris-")); ++ vi.mocked(os.homedir).mockReturnValue(home); ++ vi.stubEnv("XDG_DATA_HOME", path.join(home, ".local/share")); ++ vi.stubEnv("XDG_CONFIG_HOME", path.join(home, ".config")); ++ vi.stubEnv("PATH", path.join(home, "bin")); ++ vi.stubEnv("LUTRIS_BINARY", "lutris"); ++ [{ dataPath: data, configPath: config }] = lutrisRoots(); ++ games = path.join(config, "games"); ++ resetCompatibilityPathCache(); ++ }); ++ afterEach(() => { ++ vi.unstubAllEnvs(); ++ resetCompatibilityPathCache(); ++ fs.rmSync(home, { recursive: true, force: true }); ++ }); ++ ++ it("reads installed Wine games only and finds the GOG root of a nested executable", () => { ++ const { gameRoot } = gogGame(); ++ const db = new DatabaseSync(path.join(data, "pga.db")); ++ const insert = db.prepare( ++ "INSERT INTO games (id, name, slug, runner, installed, directory, configpath) VALUES (?, ?, ?, ?, ?, ?, ?)", ++ ); ++ // Lutris imports Steam games and runs native ones without a prefix. ++ insert.run(4, "Steam Game", "steam-game", "steam", 1, path.join(home, "steam"), "steam-game-1"); ++ insert.run(5, "Native", "native", "linux", 1, path.join(home, "native"), "native-1"); ++ insert.run(6, "Removed", "removed", "wine", 0, path.join(home, "removed"), "removed-1"); ++ db.close(); ++ expect(readLutrisGames()).toEqual([ ++ expect.objectContaining({ ++ appid: "1207658691", ++ id: 3, ++ name: "Example Game", ++ gameStoreId: "lutris", ++ gamePath: gameRoot, ++ wineVersion: "wine-ge-8-26", ++ env: { DXVK_HUD: "compiler", GAME_VALUE: "1" }, ++ }), ++ ]); ++ }); ++ ++ it("never exposes Lutris' row ID as a store ID", () => { ++ const prefix = prefixAt(path.join(home, "Games/manual")); ++ database([ ++ { id: 220, name: "Manual", slug: "manual", directory: prefix, configpath: "manual-1" }, ++ ]); ++ yaml(path.join(games, "manual-1.yml"), { ++ game: { exe: path.join(prefix, "game.exe"), prefix }, ++ }); ++ expect(readLutrisGames().map((game) => game.appid)).toEqual(["lutris-manual"]); ++ }); ++ ++ it("reads PyYAML's folded long paths and configuration kept in the data directory", () => { ++ const prefix = prefixAt(path.join(home, "Games/long")); ++ const exe = path.join( ++ prefix, ++ "drive_c/Program Files/A Publisher With A Long Name/A Game Title That Is Long Enough To Wrap/game.exe", ++ ); ++ fs.mkdirSync(path.dirname(exe), { recursive: true }); ++ fs.writeFileSync(exe, ""); ++ database([{ id: 1, name: "Long", slug: "long", directory: prefix, configpath: "long-1" }]); ++ const [head, tail] = [ ++ exe.slice(0, exe.indexOf(" Long Enough")), ++ exe.slice(exe.indexOf(" Long Enough") + 1), ++ ]; ++ yaml( ++ path.join(data, "games/long-1.yml"), ++ `game:\n exe: ${head}\n ${tail}\n prefix: ${prefix}\nwine:\n version: system\n`, ++ ); ++ expect(readLutrisGames()[0]).toMatchObject({ exe, gamePath: path.dirname(exe) }); ++ }); ++ ++ it("returns no games while the database cannot be read", () => { ++ fs.mkdirSync(data, { recursive: true }); ++ fs.writeFileSync(path.join(data, "pga.db"), "not a database"); ++ expect(readLutrisGames()).toEqual([]); ++ }); ++ ++ it("identifies a game folder inside the prefix and resolves its prefix", async () => { ++ const { prefix, gameRoot } = gogGame(); ++ const store = new LutrisStore(); ++ expect(await store.identifyGame(path.join(gameRoot, "Data"))).toBe(true); ++ expect(await store.identifyGame(path.join(home, "elsewhere"))).toBe(false); ++ expect(lutrisPrefix({ path: gameRoot, store: "lutris" })).toBe(prefix); ++ expect(selectedGamePrefix({ path: gameRoot, store: "lutris", executable: "Example.exe" })).toBe( ++ prefix, ++ ); ++ expect((await store.findByAppId(["220", "1207658691"])).id).toBe(3); ++ expect((await store.findByName("example game")).id).toBe(3); ++ }); ++ ++ it("shares one prefix between a launcher and its games, but launches only an exact match", () => { ++ const prefix = prefixAt(path.join(home, "Games/battlenet")); ++ const launcher = path.join(prefix, "drive_c/Program Files (x86)/Battle.net"); ++ const hots = path.join(prefix, "drive_c/Program Files (x86)/Heroes of the Storm"); ++ fs.mkdirSync(launcher, { recursive: true }); ++ fs.mkdirSync(hots, { recursive: true }); ++ fs.writeFileSync(path.join(launcher, "Battle.net.exe"), ""); ++ database([ ++ { ++ id: 1, ++ name: "Battle.net", ++ slug: "battlenet", ++ directory: prefix, ++ configpath: "battlenet-1", ++ }, ++ { id: 2, name: "Heroes of the Storm", slug: "hots", directory: prefix, configpath: "hots-1" }, ++ ]); ++ for (const name of ["battlenet-1", "hots-1"]) ++ yaml(path.join(games, `${name}.yml`), { ++ game: { exe: "drive_c/Program Files (x86)/Battle.net/Battle.net.exe", prefix }, ++ wine: { version: "wine-staging-x86_64" }, ++ }); ++ expect(lutrisPrefix({ path: hots, store: "lutris" })).toBe(prefix); ++ // Both entries start the launcher: neither is the game itself, and for the ++ // launcher folder there is no single entry to pick. ++ expect(findLutrisLaunchGame({ path: hots, store: "lutris" })).toBeUndefined(); ++ expect(findLutrisLaunchGame({ path: launcher, store: "lutris" })).toBeUndefined(); ++ fs.writeFileSync(path.join(hots, "HeroesSwitcher.exe"), ""); ++ yaml(path.join(games, "hots-1.yml"), { ++ game: { exe: "drive_c/Program Files (x86)/Heroes of the Storm/HeroesSwitcher.exe", prefix }, ++ }); ++ expect(findLutrisLaunchGame({ path: hots, store: "lutris" })?.id).toBe(2); ++ }); ++ ++ it("matches a game folder linked into the prefix from another disk", async () => { ++ const prefix = prefixAt(path.join(home, "Games/battlenet")); ++ const disk = path.join(home, "disk/Games/Heroes of the Storm"); ++ fs.mkdirSync(disk, { recursive: true }); ++ fs.writeFileSync(path.join(disk, "HeroesSwitcher.exe"), ""); ++ const linked = path.join(prefix, "drive_c/Program Files (x86)/Heroes of the Storm"); ++ fs.mkdirSync(path.dirname(linked), { recursive: true }); ++ fs.symlinkSync(disk, linked); ++ database([ ++ { id: 2, name: "Heroes of the Storm", slug: "hots", directory: prefix, configpath: "hots-1" }, ++ ]); ++ yaml(path.join(games, "hots-1.yml"), { ++ game: { exe: "drive_c/Program Files (x86)/Heroes of the Storm/HeroesSwitcher.exe", prefix }, ++ }); ++ expect(await new LutrisStore().identifyGame(linked)).toBe(true); ++ expect(lutrisPrefix({ path: linked, store: "lutris" })).toBe(prefix); ++ expect(findLutrisLaunchGame({ path: linked, store: "lutris" })?.id).toBe(2); ++ // The resolved folder on the other disk is the same game. ++ expect(lutrisPrefix({ path: disk, store: "lutris" })).toBe(prefix); ++ }); ++ ++ it("refuses to choose between different prefixes and tolerates a prefix not created yet", () => { ++ const outer = prefixAt(path.join(home, "Games/outer")); ++ const inner = prefixAt(path.join(outer, "drive_c/inner")); ++ database([ ++ { id: 1, name: "Outer", slug: "outer", directory: outer, configpath: "outer-1" }, ++ { id: 2, name: "Inner", slug: "inner", directory: inner, configpath: "inner-1" }, ++ { id: 3, name: "Fresh", slug: "fresh", directory: outer, configpath: "fresh-1" }, ++ ]); ++ yaml(path.join(games, "outer-1.yml"), { game: { prefix: outer } }); ++ yaml(path.join(games, "inner-1.yml"), { game: { prefix: inner } }); ++ yaml(path.join(games, "fresh-1.yml"), { game: { prefix: path.join(home, "Games/fresh") } }); ++ expect(() => lutrisPrefix({ path: path.join(inner, "drive_c/Game"), store: "lutris" })).toThrow( ++ /different prefixes/, ++ ); ++ expect(lutrisPrefix({ path: path.join(outer, "drive_c/Other"), store: "lutris" })).toBe(outer); ++ }); ++ ++ it("uses a Proton-style pfx directory inside the configured prefix", () => { ++ const prefix = path.join(home, "Games/proton"); ++ prefixAt(path.join(prefix, "pfx")); ++ database([ ++ { id: 1, name: "Proton", slug: "proton", directory: prefix, configpath: "proton-1" }, ++ ]); ++ yaml(path.join(games, "proton-1.yml"), { game: { prefix } }); ++ expect(lutrisPrefix({ path: path.join(prefix, "pfx/drive_c/Game"), store: "lutris" })).toBe( ++ path.join(prefix, "pfx"), ++ ); ++ }); ++ ++ it("launches through the native or Flatpak Lutris client", () => { ++ gogGame(7); ++ const lutris = program("lutris"); ++ const [game] = readLutrisGames(); ++ expect(lutrisLaunchCommand(game)).toEqual({ ++ executable: lutris, ++ args: ["lutris:rungameid/7"], ++ options: { shell: false }, ++ }); ++ ++ fs.rmSync(path.join(home, ".local"), { recursive: true }); ++ const flatpak = program("flatpak"); ++ const flatpakData = path.join(home, ".var/app/net.lutris.Lutris/data/lutris"); ++ games = path.join(home, ".var/app/net.lutris.Lutris/config/lutris/games"); ++ data = flatpakData; ++ gogGame(8); ++ const [flatpakGame] = readLutrisGames(); ++ expect(flatpakGame.root.flatpak).toBe(true); ++ expect(lutrisLaunchCommand(flatpakGame)).toMatchObject({ ++ executable: flatpak, ++ args: ["run", "net.lutris.Lutris", "lutris:rungameid/8"], ++ }); ++ }); ++ ++ it("runs tools with the game's Wine build and environment", async () => { ++ const { prefix, gameRoot } = gogGame(); ++ new LutrisStore(); ++ const wine = wineBuild("wine-ge-8-26"); ++ yaml(path.join(config, "system.yml"), { system: { env: { SHARED: "yes", DXVK_HUD: "0" } } }); ++ const command = await resolveGameTool( ++ path.join(gameRoot, "Tools/Edit.exe"), ++ [path.join(gameRoot, "Data")], ++ { env: { EXTRA: "1" } }, ++ { path: gameRoot, store: "lutris", executable: "bin/x64/Example.exe" }, ++ ); ++ expect(command.executable).toBe(wine); ++ expect(command.args[0]).toBe(path.join(gameRoot, "Tools/Edit.exe")); ++ expect(command.args[1]).toMatch(/^Z:\\/); ++ expect(command.options).toMatchObject({ ++ shell: false, ++ env: { ++ WINEPREFIX: prefix, ++ EXTRA: "1", ++ SHARED: "yes", ++ DXVK_HUD: "compiler", ++ GAME_VALUE: "1", ++ }, ++ }); ++ }); ++ ++ it.each([ ++ ["system", /^wine$/], ++ ["GE-Proton9-20", /Proton.*not supported/], ++ [undefined, /Select a Wine version/], ++ ["missing-build", /not installed/], ++ ])("resolves the %s Wine version", async (version, expected) => { ++ const { gameRoot } = gogGame(); ++ new LutrisStore(); ++ program("wine"); ++ yaml(path.join(games, "example-1700000000.yml"), { ++ game: { ++ exe: "drive_c/GOG Games/Example Game/bin/x64/Example.exe", ++ prefix: path.join(home, "Games/example"), ++ }, ++ ...(version === undefined ? {} : { wine: { version } }), ++ }); ++ const run = resolveGameTool( ++ path.join(gameRoot, "Tool.exe"), ++ [], ++ {}, ++ { ++ path: gameRoot, ++ store: "lutris", ++ }, ++ ); ++ if (expected.source === "^wine$") { ++ expect((await run).executable).toBe("wine"); ++ } else { ++ await expect(run).rejects.toThrow(expected); ++ } ++ }); ++ ++ it("falls back to the runner's default Wine version and runs Flatpak tools inside Lutris", async () => { ++ vi.stubEnv("XDG_DATA_HOME", path.join(home, "unused")); ++ vi.stubEnv("XDG_CONFIG_HOME", path.join(home, "unused-config")); ++ data = path.join(home, ".var/app/net.lutris.Lutris/data/lutris"); ++ config = path.join(home, ".var/app/net.lutris.Lutris/config/lutris"); ++ games = path.join(config, "games"); ++ const { prefix, gameRoot } = gogGame(); ++ yaml(path.join(games, "example-1700000000.yml"), { ++ game: { exe: "drive_c/GOG Games/Example Game/bin/x64/Example.exe", prefix }, ++ }); ++ yaml(path.join(config, "runners/wine.yml"), { wine: { version: "lutris-7.2" } }); ++ const wine = wineBuild("lutris-7.2", data); ++ const flatpak = program("flatpak"); ++ new LutrisStore(); ++ const command = await resolveGameTool( ++ path.join(gameRoot, "Tool.exe"), ++ ["-a"], ++ {}, ++ { ++ path: gameRoot, ++ store: "lutris", ++ }, ++ ); ++ expect(command.executable).toBe(flatpak); ++ expect(command.args).toEqual([ ++ "run", ++ `--command=${wine}`, ++ `--env=WINEPREFIX=${prefix}`, ++ "net.lutris.Lutris", ++ path.join(gameRoot, "Tool.exe"), ++ "-a", ++ ]); ++ }); ++}); +diff --git a/src/renderer/src/util/Lutris.ts b/src/renderer/src/util/Lutris.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/util/Lutris.ts +@@ -0,0 +1,112 @@ ++import Bluebird from "bluebird"; ++ ++import type { IExtensionApi } from "../types/IExtensionContext"; ++import { ++ GameEntryNotFound, ++ GameStoreNotFound, ++ type IGameStore, ++ type IGameStoreSnapshot, ++} from "../types/IGameStore"; ++import { registerCompatibilityStore, resetCompatibilityPathCache } from "./compatibilityPaths"; ++import { registerGameToolRunner } from "./linux/gameTools"; ++import { ++ lutrisLaunchCommand, ++ lutrisLauncher, ++ lutrisPrefix, ++ lutrisRoots, ++ lutrisToolCommand, ++ matchLutrisGames, ++ readLutrisGames, ++ type LutrisGame, ++} from "./linux/lutris"; ++ ++/** ++ * Windows games installed through Lutris' Wine runner. Games added by hand are ++ * identified by their folder, so Vortex can use the game's own prefix and Wine. ++ */ ++export class LutrisStore implements IGameStore { ++ public readonly id = "lutris"; ++ public readonly name = "Lutris"; ++ // After the native stores, so a game they know is not claimed by Lutris. ++ public readonly priority = 70; ++ private games: LutrisGame[] | undefined; ++ private installed = false; ++ public constructor() { ++ registerCompatibilityStore(this.id, lutrisPrefix); ++ registerGameToolRunner(this.id, lutrisToolCommand); ++ } ++ public allGames(): Bluebird { ++ return Bluebird.try(() => { ++ if (this.games === undefined) { ++ this.games = readLutrisGames(); ++ this.installed = lutrisRoots().some((root) => lutrisLauncher(root.flatpak) !== undefined); ++ } ++ return this.games; ++ }); ++ } ++ public snapshot(): IGameStoreSnapshot { ++ return { entries: this.games ?? [], isInstalled: this.installed }; ++ } ++ public reloadGames(): Bluebird { ++ this.games = undefined; ++ resetCompatibilityPathCache(); ++ return this.allGames().then(() => undefined); ++ } ++ private find(predicate: (game: LutrisGame) => boolean, query: string): Bluebird { ++ return this.allGames().then((games) => { ++ const matches = games.filter(predicate); ++ if (matches.length === 0) throw new GameEntryNotFound(query, this.id); ++ if (matches.length > 1) ++ throw new Error( ++ "Several Lutris games match this game. Select the game directory manually.", ++ ); ++ return matches[0]; ++ }); ++ } ++ public findByAppId(id: string | string[]): Bluebird { ++ const ids = Array.isArray(id) ? id : [id]; ++ return this.find((game) => ids.includes(game.appid), ids.join(", ")); ++ } ++ public findByName(name: string): Bluebird { ++ return Bluebird.try(() => new RegExp(`^(?:${name})$`, "i")).then((pattern) => ++ this.find((game) => pattern.test(game.name), name), ++ ); ++ } ++ public identifyGame(gamePath: string): Bluebird { ++ return Bluebird.try(() => matchLutrisGames(gamePath).length > 0).catch(() => false); ++ } ++ public getGameStorePath(): Bluebird { ++ return Bluebird.resolve( ++ lutrisRoots() ++ .map((root) => lutrisLauncher(root.flatpak)?.executable) ++ .find(Boolean), ++ ); ++ } ++ public isGameStoreInstalled(): Bluebird { ++ return this.getGameStorePath().then(Boolean); ++ } ++ public isGameInstalled(name: string): Bluebird { ++ return this.findByName(name) ++ .then(() => true) ++ .catch(GameEntryNotFound, () => false); ++ } ++ public launchGame( ++ appInfo: string | { appId: string; parameters?: string[] }, ++ api?: IExtensionApi, ++ ): Bluebird { ++ return this.isGameStoreInstalled() ++ .then((installed) => { ++ if (!installed) throw new GameStoreNotFound(this.name); ++ return this.findByAppId(typeof appInfo === "string" ? appInfo : appInfo.appId); ++ }) ++ .then((game) => { ++ if (!api) throw new Error("Lutris launch requires the Vortex application API"); ++ const command = lutrisLaunchCommand(game); ++ return api.runExecutable(command.executable, command.args, { ++ ...command.options, ++ cwd: game.gamePath, ++ suggestDeploy: true, ++ }); ++ }); ++ } ++} +diff --git a/src/renderer/src/util/StarterInfo.ts b/src/renderer/src/util/StarterInfo.ts +--- a/src/renderer/src/util/StarterInfo.ts ++++ b/src/renderer/src/util/StarterInfo.ts +@@ -26,6 +26,7 @@ import { + import { emitGameLaunched, recordLaunchExit } from "./gameLaunchAnalytics"; + import getVortexPath from "./getVortexPath"; + import { findHeroicGame, heroicLaunchCommand } from "./linux/heroic"; ++import { findLutrisLaunchGame, lutrisLaunchCommand } from "./linux/lutris"; + import { resolvePlatformPath } from "./nativeExecutable"; + import type { Steam, ISteamEntry } from "./Steam"; + import { getSafe } from "./storeHelper"; +@@ -278,6 +279,36 @@ class StarterInfo implements IStarterInfo { + } + } + ++ // Lutris applies its own game settings. Extra launch parameters cannot be ++ // passed through its URI, so those launches go through Lutris' Wine instead. ++ if ( ++ process.platform === "linux" && ++ info.isGame && ++ info.store === "lutris" && ++ (info.commandLine ?? []).length === 0 ++ ) { ++ try { ++ const discovery = api.store.getState().settings.gameMode.discovered[info.gameId]; ++ const lutrisGame = findLutrisLaunchGame({ path: discovery?.path, store: info.store }); ++ if (lutrisGame) { ++ const command = lutrisLaunchCommand(lutrisGame); ++ await api.runExecutable(command.executable, command.args, { ++ ...command.options, ++ cwd: lutrisGame.gamePath, ++ env: info.environment, ++ suggestDeploy: true, ++ detach: true, ++ // Lutris may keep running after the game exits. ++ onSpawned: spawned, ++ }); ++ return; ++ } ++ } catch (err) { ++ onShowError("Failed to start game through Lutris", err, false); ++ return; ++ } ++ } ++ + const steamGameEntry = info.isGame ? await findSteamEntry(info) : undefined; + if (steamGameEntry !== undefined) { + const steamStore = storeLookup.getGameStore(getGameStoresSafe(), "steam") as Steam; +diff --git a/src/renderer/src/util/linux/lutris.ts b/src/renderer/src/util/linux/lutris.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/util/linux/lutris.ts +@@ -0,0 +1,349 @@ ++import * as fs from "node:fs"; ++import * as os from "node:os"; ++import * as path from "node:path"; ++import { DatabaseSync } from "node:sqlite"; ++ ++import { load } from "js-yaml"; ++ ++import type { CompatibilityGame } from "../compatibilityPaths"; ++import type { ToolCommand } from "./gameTools"; ++import { findProgram } from "./heroic"; ++import { canonicalPath } from "./steamLibraries"; ++ ++const FLATPAK_ID = "net.lutris.Lutris"; ++ ++export interface LutrisRoot { ++ dataPath: string; ++ configPath: string; ++ flatpak: boolean; ++} ++ ++export interface LutrisGame { ++ /** Store-wide ID: the service's own ID (e.g. a GOG product ID), never Lutris' row ID. */ ++ appid: string; ++ name: string; ++ /** The folder of the configured executable, or the game directory. */ ++ gamePath: string; ++ gameStoreId: "lutris"; ++ /** Lutris' database ID, used for lutris:rungameid. */ ++ id: number; ++ slug: string; ++ directory?: string; ++ prefix?: string; ++ exe?: string; ++ /** Prefix and directory, as configured and resolved, for matching game folders. */ ++ locations: string[]; ++ /** The executable, as configured and resolved. */ ++ exePaths: string[]; ++ wineVersion?: string; ++ env: Record; ++ root: LutrisRoot; ++} ++ ++export function lutrisRoots(): LutrisRoot[] { ++ const home = os.homedir(); ++ const flatpak = path.join(home, ".var/app", FLATPAK_ID); ++ return [ ++ { ++ dataPath: path.join(process.env.XDG_DATA_HOME ?? path.join(home, ".local/share"), "lutris"), ++ configPath: path.join(process.env.XDG_CONFIG_HOME ?? path.join(home, ".config"), "lutris"), ++ flatpak: false, ++ }, ++ { ++ dataPath: path.join(flatpak, "data/lutris"), ++ configPath: path.join(flatpak, "config/lutris"), ++ flatpak: true, ++ }, ++ ]; ++} ++ ++function readYaml(file: string): any { ++ try { ++ return load(fs.readFileSync(file, "utf8")); ++ } catch { ++ return undefined; ++ } ++} ++ ++function absolute(value: unknown): string | undefined { ++ if (typeof value !== "string") return undefined; ++ const expanded = value.replace(/^~(?=\/|$)/, os.homedir()); ++ return path.isAbsolute(expanded) ? path.normalize(expanded) : undefined; ++} ++ ++function stringEnv(value: unknown): Record { ++ if (value === null || typeof value !== "object") return {}; ++ return Object.fromEntries( ++ Object.entries(value).flatMap(([key, entry]) => ++ key && ["string", "number", "boolean"].includes(typeof entry) ? [[key, String(entry)]] : [], ++ ), ++ ); ++} ++ ++/** Lutris 0.5.17+ moved its configuration into the data directory; older setups keep it in ~/.config. */ ++function configFile(root: LutrisRoot, ...parts: string[]): string | undefined { ++ return [root.configPath, root.dataPath] ++ .map((base) => path.join(base, ...parts)) ++ .find((file) => fs.existsSync(file)); ++} ++ ++function isInside(root: string, candidate: string): boolean { ++ const relative = path.relative(root, candidate); ++ return ( ++ relative === "" || ++ (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) ++ ); ++} ++ ++/** ++ * A path as configured and with symlinks resolved. Game folders are often ++ * linked into a prefix from another disk, so either form may be the one that ++ * lies inside the prefix. ++ */ ++function aliases(input: string): string[] { ++ const resolved = path.resolve(input); ++ const canonical = canonicalPath(input); ++ return resolved === canonical ? [resolved] : [resolved, canonical]; ++} ++ ++function insideAny(roots: string[], candidates: string[]): boolean { ++ return roots.some((root) => candidates.some((candidate) => isInside(root, candidate))); ++} ++ ++interface LutrisRow { ++ id: number; ++ name: string | null; ++ slug: string | null; ++ directory: string | null; ++ configpath: string | null; ++ service: string | null; ++ service_id: string | null; ++} ++ ++function readRows(database: string): LutrisRow[] { ++ let db: DatabaseSync | undefined; ++ try { ++ db = new DatabaseSync(database, { readOnly: true }); ++ // Only Windows games installed through Lutris' Wine runner have a prefix. ++ // Steam games imported by Lutris and native games stay with their own stores. ++ return db ++ .prepare( ++ "SELECT id, name, slug, directory, configpath, service, service_id FROM games " + ++ "WHERE installed = 1 AND runner = 'wine'", ++ ) ++ .all() as unknown as LutrisRow[]; ++ } catch { ++ // Lutris may be writing to its database; the next scan tries again. ++ return []; ++ } finally { ++ db?.close(); ++ } ++} ++ ++function gameRoot(exe: string | undefined, directory: string | undefined, row: LutrisRow) { ++ if (exe === undefined) return directory; ++ // GOG and Epic installs mark their root folder; game extensions expect that one. ++ const markers = ++ row.service === "gog" && row.service_id ++ ? [`goggame-${row.service_id}.info`] ++ : row.service === "egs" ++ ? [".egstore"] ++ : []; ++ const stop = directory ?? path.parse(exe).root; ++ for (let current = path.dirname(exe); isInside(stop, current); current = path.dirname(current)) { ++ if (markers.some((marker) => fs.existsSync(path.join(current, marker)))) return current; ++ if (current === stop || current === path.dirname(current)) break; ++ } ++ return path.dirname(exe); ++} ++ ++export function readLutrisGames(): LutrisGame[] { ++ const games: LutrisGame[] = []; ++ for (const root of lutrisRoots()) { ++ const database = path.join(root.dataPath, "pga.db"); ++ if (!fs.existsSync(database)) continue; ++ const runnerConfig = readYaml(configFile(root, "runners/wine.yml") ?? "") ?? {}; ++ const systemConfig = readYaml(configFile(root, "system.yml") ?? "") ?? {}; ++ for (const row of readRows(database)) { ++ if (!row.configpath || /[\\/]/.test(row.configpath)) continue; ++ const config = readYaml(configFile(root, "games", `${row.configpath}.yml`) ?? ""); ++ if (config === undefined || config === null || typeof config !== "object") continue; ++ const directory = absolute(row.directory); ++ const prefix = absolute(config.game?.prefix); ++ const configuredExe = typeof config.game?.exe === "string" ? config.game.exe : undefined; ++ const exe = ++ absolute(configuredExe) ?? ++ (configuredExe === undefined ++ ? undefined ++ : [directory, prefix] ++ .filter((base): base is string => base !== undefined) ++ .map((base) => path.join(base, configuredExe)) ++ .find((candidate) => fs.existsSync(candidate))); ++ const location = gameRoot(exe, directory, row) ?? prefix; ++ if (location === undefined) continue; ++ const serviceId = ++ typeof row.service === "string" && row.service && row.service_id ++ ? row.service_id ++ : undefined; ++ games.push({ ++ appid: serviceId ?? `lutris-${row.slug ?? row.id}`, ++ name: row.name ?? config.name ?? row.slug ?? String(row.id), ++ gamePath: canonicalPath(location), ++ gameStoreId: "lutris", ++ id: row.id, ++ slug: row.slug ?? "", ++ directory: directory && canonicalPath(directory), ++ prefix: prefix && canonicalPath(prefix), ++ exe: exe && canonicalPath(exe), ++ locations: [prefix, directory] ++ .filter((entry): entry is string => entry !== undefined) ++ .flatMap(aliases), ++ exePaths: exe ? aliases(exe) : [], ++ wineVersion: ++ typeof config.wine?.version === "string" ++ ? config.wine.version ++ : typeof runnerConfig.wine?.version === "string" ++ ? runnerConfig.wine.version ++ : undefined, ++ env: { ...stringEnv(systemConfig.system?.env), ...stringEnv(config.system?.env) }, ++ root, ++ }); ++ } ++ } ++ return games; ++} ++ ++/** Lutris games whose prefix or install directory contains the game folder, or that start an executable inside it. */ ++export function matchLutrisGames(gamePath: string | undefined): LutrisGame[] { ++ if (!gamePath) return []; ++ const targets = aliases(gamePath); ++ return readLutrisGames().filter( ++ (entry) => insideAny(entry.locations, targets) || insideAny(targets, entry.exePaths), ++ ); ++} ++ ++export function lutrisPrefixOf(entry: LutrisGame): string | undefined { ++ if (!entry.prefix) return undefined; ++ const direct = fs.existsSync(path.join(entry.prefix, "drive_c")); ++ const nested = fs.existsSync(path.join(entry.prefix, "pfx/drive_c")); ++ if ( ++ direct && ++ nested && ++ canonicalPath(entry.prefix) !== canonicalPath(path.join(entry.prefix, "pfx")) ++ ) ++ throw new Error("The Lutris prefix contains two different Windows installations"); ++ if (direct) return entry.prefix; ++ return nested ? path.join(entry.prefix, "pfx") : undefined; ++} ++ ++/** The prefix shared by every Lutris game that contains the selected game folder. */ ++export function lutrisPrefix(game: CompatibilityGame): string | undefined { ++ const matches = matchLutrisGames(game.path); ++ // A game that was never launched has no prefix yet; that is not a conflict. ++ const prefixes = [ ++ ...new Set( ++ matches ++ .map((entry) => lutrisPrefixOf(entry)) ++ .filter((prefix): prefix is string => prefix !== undefined) ++ .map(canonicalPath), ++ ), ++ ]; ++ if (prefixes.length > 1) ++ throw new Error( ++ "Several Lutris games with different prefixes contain this game. Set WINEPREFIX for it explicitly.", ++ ); ++ return prefixes[0]; ++} ++ ++/** ++ * The Lutris game to launch: the one whose executable lies inside the selected ++ * game folder. Several games can share a prefix (e.g. a launcher and its games), ++ * so without a unique executable match the caller should not launch through Lutris. ++ */ ++export function findLutrisLaunchGame(game: CompatibilityGame): LutrisGame | undefined { ++ if (!game.path) return undefined; ++ const targets = aliases(game.path); ++ const candidates = matchLutrisGames(game.path).filter((entry) => ++ insideAny(targets, entry.exePaths), ++ ); ++ return candidates.length === 1 ? candidates[0] : undefined; ++} ++ ++export function lutrisLauncher( ++ flatpak: boolean, ++): { executable: string; args: string[] } | undefined { ++ if (!lutrisRoots().some((root) => root.flatpak === flatpak && fs.existsSync(root.dataPath))) ++ return undefined; ++ const executable = flatpak ++ ? findProgram("flatpak") ++ : findProgram(process.env.LUTRIS_BINARY ?? "lutris"); ++ return executable ? { executable, args: flatpak ? ["run", FLATPAK_ID] : [] } : undefined; ++} ++ ++export function lutrisLaunchCommand(entry: LutrisGame): ToolCommand { ++ const launcher = lutrisLauncher(entry.root.flatpak); ++ if (!launcher) ++ throw new Error("Lutris was not found. Install Lutris or set LUTRIS_BINARY to its executable."); ++ return { ++ executable: launcher.executable, ++ args: [...launcher.args, `lutris:rungameid/${entry.id}`], ++ options: { shell: false }, ++ }; ++} ++ ++function wineBinary(entry: LutrisGame): string { ++ const version = entry.wineVersion; ++ if (!version) throw new Error("Select a Wine version for this game in Lutris"); ++ if (version === "system") return "wine"; ++ const configured = absolute(version); ++ if (configured && fs.existsSync(configured)) return configured; ++ const build = path.join(entry.root.dataPath, "runners/wine", version); ++ for (const binary of ["bin/wine", "bin/wine64"]) { ++ if (fs.existsSync(path.join(build, binary))) return path.join(build, binary); ++ } ++ if (/proton/i.test(version) || fs.existsSync(path.join(build, "proton"))) ++ throw new Error( ++ `Running tools with Lutris' Proton (${version}) is not supported yet. Select a Wine version for this game in Lutris.`, ++ ); ++ throw new Error(`Lutris' Wine version ${version} is not installed`); ++} ++ ++/** Run a Windows tool with the Wine build and environment Lutris uses for the selected game. */ ++export async function lutrisToolCommand( ++ command: ToolCommand, ++ selected: CompatibilityGame, ++): Promise { ++ const matches = matchLutrisGames(selected.path); ++ if (matches.length === 0) throw new Error("The selected game is not registered in Lutris"); ++ const prefix = command.options.env?.WINEPREFIX ?? lutrisPrefix(selected); ++ if (!prefix || !fs.existsSync(path.join(prefix, "drive_c"))) ++ throw new Error("The Lutris Wine prefix is unavailable. Launch the game once in Lutris."); ++ // A launcher and its games may share one prefix with different settings. ++ const launch = findLutrisLaunchGame(selected); ++ const versions = [...new Set(matches.map((entry) => entry.wineVersion))]; ++ const entry = launch ?? (versions.length === 1 ? matches[0] : undefined); ++ if (!entry) ++ throw new Error( ++ "Several Lutris games share this prefix with different Wine versions. Set WINEPREFIX for the tool explicitly.", ++ ); ++ let executable = wineBinary(entry); ++ const env: Record = { ++ ...entry.env, ++ ...command.options.env, ++ WINEPREFIX: prefix, ++ }; ++ let args = [command.executable, ...command.args]; ++ if (entry.root.flatpak) { ++ const flatpak = findProgram("flatpak"); ++ if (!flatpak) throw new Error("Flatpak was not found"); ++ args = [ ++ "run", ++ `--command=${executable}`, ++ ...Object.entries(env).map(([key, value]) => `--env=${key}=${value}`), ++ FLATPAK_ID, ++ ...args, ++ ]; ++ executable = flatpak; ++ } else if (!findProgram(executable)) throw new Error(`Lutris' Wine is missing: ${executable}`); ++ return { executable, args, options: { ...command.options, shell: false, env } }; ++} diff --git a/patches/0040-linux-bottles-store.patch b/patches/0040-linux-bottles-store.patch new file mode 100644 index 0000000..a37058d --- /dev/null +++ b/patches/0040-linux-bottles-store.patch @@ -0,0 +1,801 @@ +diff --git a/src/renderer/src/extensions/gamemode_management/GameModeManager.ts b/src/renderer/src/extensions/gamemode_management/GameModeManager.ts +--- a/src/renderer/src/extensions/gamemode_management/GameModeManager.ts ++++ b/src/renderer/src/extensions/gamemode_management/GameModeManager.ts +@@ -23,6 +23,7 @@ import { XboxLauncher } from "@/util/xbox/XboxLauncher"; + + import { setNextProfile } from "../../actions"; + import { getNormalizeFunc } from "../../util/api"; ++import { BottlesStore } from "../../util/Bottles"; + import { ProcessCanceled, SetupError, UserCanceled } from "../../util/CustomErrors"; + import EpicGamesLauncher from "../../util/EpicGamesLauncher"; + import * as fs from "../../util/fs"; +@@ -85,7 +86,7 @@ class GameModeManager { + UPlayLauncher.create(), + XboxLauncher.create(api), + ...(process.platform === "linux" +- ? [new HeroicStore("gog"), new HeroicStore("amazon"), new LutrisStore()] ++ ? [new HeroicStore("gog"), new HeroicStore("amazon"), new LutrisStore(), new BottlesStore()] + : []), + ].filter(Boolean); + this.mActiveSearch = null; +diff --git a/src/renderer/src/util/Bottles.test.ts b/src/renderer/src/util/Bottles.test.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/util/Bottles.test.ts +@@ -0,0 +1,283 @@ ++import { execFileSync } from "node:child_process"; ++import * as fs from "node:fs"; ++import * as os from "node:os"; ++import * as path from "node:path"; ++ ++import { dump, load } from "js-yaml"; ++import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; ++ ++import { BottlesStore } from "./Bottles"; ++import { resetCompatibilityPathCache, selectedGamePrefix } from "./compatibilityPaths"; ++import { ++ bottlesLaunchCommand, ++ bottlesPrefix, ++ bottlesRoots, ++ findBottlesLaunch, ++ readBottles, ++ readBottlesGames, ++ shellQuote, ++} from "./linux/bottles"; ++import { resolveGameTool } from "./linux/gameTools"; ++ ++vi.mock("node:os", async (original) => ({ ...(await original()), homedir: vi.fn() })); ++ ++describe.runIf(process.platform === "linux")("Bottles integration", () => { ++ let home: string; ++ let data: string; ++ function yaml(file: string, value: unknown): void { ++ fs.mkdirSync(path.dirname(file), { recursive: true }); ++ fs.writeFileSync(file, dump(value)); ++ } ++ function program(name: string): string { ++ const binary = path.join(home, "bin", name); ++ fs.mkdirSync(path.dirname(binary), { recursive: true }); ++ fs.writeFileSync(binary, "#!/bin/sh\n", { mode: 0o755 }); ++ return binary; ++ } ++ function runner(name: string, root = data): string { ++ const binary = path.join(root, "runners", name, "bin/wine"); ++ fs.mkdirSync(path.dirname(binary), { recursive: true }); ++ fs.writeFileSync(binary, "#!/bin/sh\n", { mode: 0o755 }); ++ return binary; ++ } ++ /** A bottle whose game lives inside drive_c, as Bottles installs it. */ ++ function bottle( ++ name: string, ++ options: { folder?: string; location?: string; root?: string; runner?: string } = {}, ++ ) { ++ const root = options.root ?? data; ++ const folder = path.join(root, "bottles", options.folder ?? name); ++ const location = options.location ?? folder; ++ fs.mkdirSync(path.join(location, "drive_c/users"), { recursive: true }); ++ const gameRoot = path.join(location, "drive_c/GOG Games/Example"); ++ fs.mkdirSync(path.join(gameRoot, "bin"), { recursive: true }); ++ fs.writeFileSync(path.join(gameRoot, "bin/Example.exe"), ""); ++ fs.writeFileSync(path.join(gameRoot, "goggame-1207658691.info"), "{}"); ++ if (options.location) yaml(path.join(folder, "placeholder.yml"), { Path: location }); ++ yaml(path.join(location, "bottle.yml"), { ++ Name: name, ++ Path: options.location ?? options.folder ?? name, ++ Custom_Path: options.location !== undefined, ++ Environment: "Gaming", ++ Runner: options.runner ?? "soda-9.0-1", ++ Environment_Variables: { DXVK_ASYNC: 1, SHARED: "yes" }, ++ External_Programs: { ++ "1f0e": { ++ id: "1f0e", ++ name: "Example Game", ++ executable: "Example.exe", ++ path: path.join(gameRoot, "bin/Example.exe"), ++ }, ++ }, ++ }); ++ return { location, gameRoot }; ++ } ++ ++ beforeEach(() => { ++ home = fs.mkdtempSync(path.join(os.tmpdir(), "vortex-bottles-")); ++ vi.mocked(os.homedir).mockReturnValue(home); ++ vi.stubEnv("XDG_DATA_HOME", path.join(home, ".local/share")); ++ vi.stubEnv("PATH", path.join(home, "bin")); ++ vi.stubEnv("BOTTLES_CLI_BINARY", "bottles-cli"); ++ [{ dataPath: data }] = bottlesRoots(); ++ resetCompatibilityPathCache(); ++ }); ++ afterEach(() => { ++ vi.unstubAllEnvs(); ++ resetCompatibilityPathCache(); ++ fs.rmSync(home, { recursive: true, force: true }); ++ }); ++ ++ it("lists programs as games rooted at the GOG folder, skipping Steam bottles", () => { ++ const { gameRoot } = bottle("Gaming"); ++ yaml(path.join(data, "bottles/SteamProton/bottle.yml"), { ++ Name: "SteamProton", ++ Environment: "Steam", ++ }); ++ expect(readBottlesGames()).toEqual([ ++ expect.objectContaining({ ++ appid: "bottles-Gaming-1f0e", ++ name: "Example Game", ++ gameStoreId: "bottles", ++ gamePath: gameRoot, ++ }), ++ ]); ++ expect(readBottles()[0]).toMatchObject({ env: { DXVK_ASYNC: "1", SHARED: "yes" } }); ++ }); ++ ++ it("follows placeholders and a custom bottles directory", () => { ++ const external = path.join(home, "disk/bottles/Default"); ++ bottle("Default", { location: external }); ++ const custom = path.join(home, "custom-bottles"); ++ yaml(path.join(data, "data.yml"), { custom_bottles_path: custom }); ++ // Like Bottles, ignore a custom directory that does not exist. ++ expect(readBottles().map((entry) => entry.name)).toEqual(["Default"]); ++ fs.mkdirSync(custom); ++ expect(readBottles()).toEqual([]); ++ bottle("Other", { root: path.dirname(custom), folder: "../custom-bottles/Other" }); ++ expect(readBottles().map((entry) => entry.name)).toEqual(["Other"]); ++ fs.rmSync(path.join(data, "data.yml")); ++ expect(readBottles()).toEqual([expect.objectContaining({ name: "Default", path: external })]); ++ }); ++ ++ it("identifies game folders inside a bottle and resolves the bottle as prefix", async () => { ++ const { location, gameRoot } = bottle("Gaming"); ++ const store = new BottlesStore(); ++ expect(await store.identifyGame(gameRoot)).toBe(true); ++ expect(await store.identifyGame(path.join(home, "elsewhere"))).toBe(false); ++ expect( ++ selectedGamePrefix({ path: gameRoot, store: "bottles", executable: "bin/Example.exe" }), ++ ).toBe(location); ++ expect((await store.findByName("example game")).program.id).toBe("1f0e"); ++ }); ++ ++ it("matches a game outside the bottle through its program and rejects two bottles", () => { ++ const { location } = bottle("Gaming"); ++ const outside = path.join(home, "Games/Outside"); ++ fs.mkdirSync(outside, { recursive: true }); ++ fs.writeFileSync(path.join(outside, "Outside.exe"), ""); ++ const config = path.join(location, "bottle.yml"); ++ const parsed = { ++ ...(load(fs.readFileSync(config, "utf8")) as object), ++ } as any; ++ parsed.External_Programs.other = { ++ id: "other", ++ name: "Outside", ++ path: path.join(outside, "Outside.exe"), ++ }; ++ yaml(config, parsed); ++ expect(bottlesPrefix({ path: outside, store: "bottles" })).toBe(location); ++ expect(findBottlesLaunch({ path: outside, store: "bottles" })?.program.name).toBe("Outside"); ++ ++ const second = bottle("Second"); ++ const shared = path.join(second.location, "bottle.yml"); ++ const secondConfig = load(fs.readFileSync(shared, "utf8")) as any; ++ secondConfig.External_Programs.other = { ++ id: "x", ++ name: "Outside", ++ path: path.join(outside, "Outside.exe"), ++ }; ++ yaml(shared, secondConfig); ++ expect(() => bottlesPrefix({ path: outside, store: "bottles" })).toThrow(/Several bottles/); ++ }); ++ ++ it("matches a game folder linked into the bottle from another disk", () => { ++ const { location } = bottle("Gaming"); ++ const disk = path.join(home, "disk/Linked Game"); ++ fs.mkdirSync(disk, { recursive: true }); ++ const linked = path.join(location, "drive_c/Games/Linked Game"); ++ fs.mkdirSync(path.dirname(linked), { recursive: true }); ++ fs.symlinkSync(disk, linked); ++ expect(bottlesPrefix({ path: linked, store: "bottles" })).toBe(location); ++ }); ++ ++ it("starts a program through bottles-cli with shell-safe arguments", () => { ++ const cli = program("bottles-cli"); ++ const { gameRoot } = bottle("Gaming"); ++ const launch = findBottlesLaunch({ path: gameRoot, store: "bottles" })!; ++ const args = ["-skip intro", "it's", "$(touch nope)"]; ++ const command = bottlesLaunchCommand(launch.bottle, launch.program, args); ++ expect(command).toEqual({ ++ executable: cli, ++ args: ["run", "-b", "Gaming", "-p", "Example Game", ...args.map(shellQuote)], ++ options: { shell: false }, ++ }); ++ // bottles-cli joins the arguments into a /bin/sh command line. ++ const echoed = execFileSync( ++ "/bin/sh", ++ ["-c", `printf '%s\\n' ${args.map(shellQuote).join(" ")}`], ++ { ++ encoding: "utf8", ++ }, ++ ); ++ expect(echoed.split("\n").slice(0, 3)).toEqual(args); ++ }); ++ ++ it("refuses to address a bottle whose name is shared", () => { ++ program("bottles-cli"); ++ bottle("Default"); ++ bottle("Default", { folder: "Default__156" }); ++ const games = readBottlesGames(); ++ expect(games).toHaveLength(2); ++ expect(() => bottlesLaunchCommand(games[0].bottle, games[0].program)).toThrow(/Rename them/); ++ }); ++ ++ it("runs tools with the bottle's runner so Vortex can wait for them", async () => { ++ const { location, gameRoot } = bottle("Gaming"); ++ new BottlesStore(); ++ const wine = runner("soda-9.0-1"); ++ const command = await resolveGameTool( ++ path.join(gameRoot, "Tools/Edit.exe"), ++ [path.join(gameRoot, "Data")], ++ { env: { EXTRA: "1" } }, ++ { path: gameRoot, store: "bottles" }, ++ ); ++ expect(command.executable).toBe(wine); ++ expect(command.args[1]).toMatch(/^Z:\\/); ++ expect(command.options.env).toMatchObject({ ++ WINEPREFIX: location, ++ EXTRA: "1", ++ DXVK_ASYNC: "1", ++ }); ++ }); ++ ++ it.each([ ++ ["sys-wine-9.0", /^wine$/], ++ ["GE-Proton9-20", /Proton runner.*not supported/], ++ ["missing-runner", /not installed/], ++ ])("resolves the %s runner", async (name, expected) => { ++ program("wine"); ++ const { gameRoot } = bottle("Gaming", { runner: name }); ++ new BottlesStore(); ++ const run = resolveGameTool( ++ path.join(gameRoot, "Tool.exe"), ++ [], ++ {}, ++ { ++ path: gameRoot, ++ store: "bottles", ++ }, ++ ); ++ if (expected.source === "^wine$") expect((await run).executable).toBe("wine"); ++ else await expect(run).rejects.toThrow(expected); ++ }); ++ ++ it("starts Flatpak programs and tools inside the Bottles sandbox", async () => { ++ vi.stubEnv("XDG_DATA_HOME", path.join(home, "unused")); ++ const flatpakData = bottlesRoots()[1].dataPath; ++ const { location, gameRoot } = bottle("Gaming", { root: flatpakData }); ++ const wine = runner("soda-9.0-1", flatpakData); ++ const flatpak = program("flatpak"); ++ new BottlesStore(); ++ const launch = findBottlesLaunch({ path: gameRoot, store: "bottles" })!; ++ expect(bottlesLaunchCommand(launch.bottle, launch.program).args).toEqual([ ++ "run", ++ "--command=bottles-cli", ++ "com.usebottles.bottles", ++ "run", ++ "-b", ++ "Gaming", ++ "-p", ++ "Example Game", ++ ]); ++ const command = await resolveGameTool( ++ path.join(gameRoot, "Tool.exe"), ++ [], ++ {}, ++ { ++ path: gameRoot, ++ store: "bottles", ++ }, ++ ); ++ expect(command.executable).toBe(flatpak); ++ expect(command.args).toEqual([ ++ "run", ++ `--command=${wine}`, ++ "--env=DXVK_ASYNC=1", ++ "--env=SHARED=yes", ++ `--env=WINEPREFIX=${location}`, ++ "com.usebottles.bottles", ++ path.join(gameRoot, "Tool.exe"), ++ ]); ++ }); ++}); +diff --git a/src/renderer/src/util/Bottles.ts b/src/renderer/src/util/Bottles.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/util/Bottles.ts +@@ -0,0 +1,117 @@ ++import Bluebird from "bluebird"; ++ ++import type { IExtensionApi } from "../types/IExtensionContext"; ++import { ++ GameEntryNotFound, ++ GameStoreNotFound, ++ type IGameStore, ++ type IGameStoreSnapshot, ++} from "../types/IGameStore"; ++import { registerCompatibilityStore, resetCompatibilityPathCache } from "./compatibilityPaths"; ++import { ++ bottlesCli, ++ bottlesLaunchCommand, ++ bottlesPrefix, ++ bottlesRoots, ++ bottlesToolCommand, ++ matchBottles, ++ readBottlesGames, ++ type BottlesGame, ++} from "./linux/bottles"; ++import { registerGameToolRunner } from "./linux/gameTools"; ++ ++/** ++ * Windows games installed in a Bottles bottle. A game folder inside a bottle, or ++ * one holding a program added to a bottle, uses that bottle's prefix and runner. ++ */ ++export class BottlesStore implements IGameStore { ++ public readonly id = "bottles"; ++ public readonly name = "Bottles"; ++ // After the native stores and Lutris, so a game they know is not claimed by Bottles. ++ public readonly priority = 75; ++ private games: BottlesGame[] | undefined; ++ private installed = false; ++ public constructor() { ++ registerCompatibilityStore(this.id, bottlesPrefix); ++ registerGameToolRunner(this.id, bottlesToolCommand); ++ } ++ public allGames(): Bluebird { ++ return Bluebird.try(() => { ++ if (this.games === undefined) { ++ this.games = readBottlesGames(); ++ this.installed = bottlesRoots().some((root) => bottlesCli(root.flatpak) !== undefined); ++ } ++ return this.games; ++ }); ++ } ++ public snapshot(): IGameStoreSnapshot { ++ return { entries: this.games ?? [], isInstalled: this.installed }; ++ } ++ public reloadGames(): Bluebird { ++ this.games = undefined; ++ resetCompatibilityPathCache(); ++ return this.allGames().then(() => undefined); ++ } ++ private find(predicate: (game: BottlesGame) => boolean, query: string): Bluebird { ++ return this.allGames().then((games) => { ++ const matches = games.filter(predicate); ++ if (matches.length === 0) throw new GameEntryNotFound(query, this.id); ++ if (matches.length > 1) ++ throw new Error( ++ "Several Bottles programs match this game. Select the game directory manually.", ++ ); ++ return matches[0]; ++ }); ++ } ++ public findByAppId(id: string | string[]): Bluebird { ++ const ids = Array.isArray(id) ? id : [id]; ++ return this.find((game) => ids.includes(game.appid), ids.join(", ")); ++ } ++ public findByName(name: string): Bluebird { ++ return Bluebird.try(() => new RegExp(`^(?:${name})$`, "i")).then((pattern) => ++ this.find((game) => pattern.test(game.name), name), ++ ); ++ } ++ public identifyGame(gamePath: string): Bluebird { ++ return Bluebird.try(() => matchBottles(gamePath).length > 0).catch(() => false); ++ } ++ public getGameStorePath(): Bluebird { ++ return Bluebird.resolve( ++ bottlesRoots() ++ .map((root) => bottlesCli(root.flatpak)?.executable) ++ .find(Boolean), ++ ); ++ } ++ public isGameStoreInstalled(): Bluebird { ++ return this.getGameStorePath().then(Boolean); ++ } ++ public isGameInstalled(name: string): Bluebird { ++ return this.findByName(name) ++ .then(() => true) ++ .catch(GameEntryNotFound, () => false); ++ } ++ public launchGame( ++ appInfo: string | { appId: string; parameters?: string[] }, ++ api?: IExtensionApi, ++ ): Bluebird { ++ return this.isGameStoreInstalled() ++ .then((installed) => { ++ if (!installed) throw new GameStoreNotFound(this.name); ++ return this.findByAppId(typeof appInfo === "string" ? appInfo : appInfo.appId); ++ }) ++ .then((game) => { ++ if (!api) throw new Error("Bottles launch requires the Vortex application API"); ++ const command = bottlesLaunchCommand( ++ game.bottle, ++ game.program, ++ typeof appInfo === "string" ? [] : appInfo.parameters, ++ ); ++ return api.runExecutable(command.executable, command.args, { ++ ...command.options, ++ cwd: game.gamePath, ++ suggestDeploy: true, ++ detach: true, ++ }); ++ }); ++ } ++} +diff --git a/src/renderer/src/util/StarterInfo.ts b/src/renderer/src/util/StarterInfo.ts +--- a/src/renderer/src/util/StarterInfo.ts ++++ b/src/renderer/src/util/StarterInfo.ts +@@ -25,6 +25,7 @@ import { + } from "./CustomErrors"; + import { emitGameLaunched, recordLaunchExit } from "./gameLaunchAnalytics"; + import getVortexPath from "./getVortexPath"; ++import { bottlesLaunchCommand, findBottlesLaunch } from "./linux/bottles"; + import { findHeroicGame, heroicLaunchCommand } from "./linux/heroic"; + import { findLutrisLaunchGame, lutrisLaunchCommand } from "./linux/lutris"; + import { resolvePlatformPath } from "./nativeExecutable"; +@@ -309,6 +310,31 @@ class StarterInfo implements IStarterInfo { + } + } + ++ // Bottles applies the program's own settings. Without a single program to ++ // start, the game runs through the bottle's runner instead. ++ if (process.platform === "linux" && info.isGame && info.store === "bottles") { ++ try { ++ const discovery = api.store.getState().settings.gameMode.discovered[info.gameId]; ++ const launch = findBottlesLaunch({ path: discovery?.path, store: info.store }); ++ if (launch) { ++ const command = bottlesLaunchCommand(launch.bottle, launch.program, info.commandLine); ++ await api.runExecutable(command.executable, command.args, { ++ ...command.options, ++ cwd: discovery.path, ++ env: info.environment, ++ suggestDeploy: true, ++ detach: true, ++ // bottles-cli exits as soon as the game has started. ++ onSpawned: spawned, ++ }); ++ return; ++ } ++ } catch (err) { ++ onShowError("Failed to start game through Bottles", err, false); ++ return; ++ } ++ } ++ + const steamGameEntry = info.isGame ? await findSteamEntry(info) : undefined; + if (steamGameEntry !== undefined) { + const steamStore = storeLookup.getGameStore(getGameStoresSafe(), "steam") as Steam; +diff --git a/src/renderer/src/util/linux/bottles.ts b/src/renderer/src/util/linux/bottles.ts +new file mode 100644 +--- /dev/null ++++ b/src/renderer/src/util/linux/bottles.ts +@@ -0,0 +1,323 @@ ++import * as fs from "node:fs"; ++import * as os from "node:os"; ++import * as path from "node:path"; ++ ++import { load } from "js-yaml"; ++ ++import type { CompatibilityGame } from "../compatibilityPaths"; ++import type { ToolCommand } from "./gameTools"; ++import { findProgram } from "./heroic"; ++import { canonicalPath } from "./steamLibraries"; ++ ++const FLATPAK_ID = "com.usebottles.bottles"; ++ ++export interface BottlesRoot { ++ /** Bottles' data directory, holding runners and the default bottles folder. */ ++ dataPath: string; ++ flatpak: boolean; ++} ++ ++export interface BottlesProgram { ++ id: string; ++ name: string; ++ exe: string; ++ /** The executable, as configured and resolved. */ ++ exePaths: string[]; ++} ++ ++export interface Bottle { ++ /** The name bottles-cli addresses the bottle by. */ ++ name: string; ++ path: string; ++ /** The bottle folder, as configured and resolved. */ ++ locations: string[]; ++ runner?: string; ++ env: Record; ++ programs: BottlesProgram[]; ++ root: BottlesRoot; ++ /** Bottles keys bottles by name, so bottles-cli cannot address a duplicate. */ ++ duplicateName: boolean; ++} ++ ++export interface BottlesGame { ++ appid: string; ++ name: string; ++ gamePath: string; ++ gameStoreId: "bottles"; ++ bottle: Bottle; ++ program: BottlesProgram; ++} ++ ++export function bottlesRoots(): BottlesRoot[] { ++ const home = os.homedir(); ++ return [ ++ { ++ dataPath: path.join(process.env.XDG_DATA_HOME ?? path.join(home, ".local/share"), "bottles"), ++ flatpak: false, ++ }, ++ { dataPath: path.join(home, ".var/app", FLATPAK_ID, "data/bottles"), flatpak: true }, ++ ]; ++} ++ ++function readYaml(file: string): any { ++ try { ++ return load(fs.readFileSync(file, "utf8")); ++ } catch { ++ return undefined; ++ } ++} ++ ++function absolute(value: unknown): string | undefined { ++ if (typeof value !== "string") return undefined; ++ const expanded = value.replace(/^~(?=\/|$)/, os.homedir()); ++ return path.isAbsolute(expanded) ? path.normalize(expanded) : undefined; ++} ++ ++function isInside(root: string, candidate: string): boolean { ++ const relative = path.relative(root, candidate); ++ return ( ++ relative === "" || ++ (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) ++ ); ++} ++ ++/** ++ * A path as configured and with symlinks resolved. Game folders are often ++ * linked into a prefix from another disk, so either form may be the one that ++ * lies inside the prefix. ++ */ ++function aliases(input: string): string[] { ++ const resolved = path.resolve(input); ++ const canonical = canonicalPath(input); ++ return resolved === canonical ? [resolved] : [resolved, canonical]; ++} ++ ++function insideAny(roots: string[], candidates: string[]): boolean { ++ return roots.some((root) => candidates.some((candidate) => isInside(root, candidate))); ++} ++ ++function stringEnv(value: unknown): Record { ++ if (value === null || typeof value !== "object") return {}; ++ return Object.fromEntries( ++ Object.entries(value).flatMap(([key, entry]) => ++ key && ["string", "number", "boolean"].includes(typeof entry) ? [[key, String(entry)]] : [], ++ ), ++ ); ++} ++ ++/** Bottles stores program paths as host paths, older entries as C:\ paths. */ ++function programExe(bottlePath: string, value: unknown): string | undefined { ++ if (typeof value !== "string") return undefined; ++ const drive = /^[cC]:[\\/](.*)$/.exec(value); ++ if (drive) return path.join(bottlePath, "drive_c", ...drive[1].split(/[\\/]+/)); ++ return absolute(value); ++} ++ ++function bottlesDirectory(root: BottlesRoot): string { ++ const custom = absolute(readYaml(path.join(root.dataPath, "data.yml"))?.custom_bottles_path); ++ return custom && fs.existsSync(custom) ? custom : path.join(root.dataPath, "bottles"); ++} ++ ++export function readBottles(): Bottle[] { ++ const bottles: Bottle[] = []; ++ for (const root of bottlesRoots()) { ++ const directory = bottlesDirectory(root); ++ let entries: string[]; ++ try { ++ entries = fs.readdirSync(directory); ++ } catch { ++ continue; ++ } ++ const found: Bottle[] = []; ++ for (const entry of entries) { ++ // A bottle stored elsewhere leaves a placeholder pointing at it. ++ const placeholder = absolute(readYaml(path.join(directory, entry, "placeholder.yml"))?.Path); ++ const location = placeholder ?? path.join(directory, entry); ++ const config = readYaml(path.join(location, "bottle.yml")); ++ if (config === undefined || config === null || typeof config !== "object") continue; ++ // Steam bottles wrap a Proton prefix that the Steam store already handles. ++ if (config.Environment === "Steam" || typeof config.Name !== "string") continue; ++ const bottlePath = canonicalPath(location); ++ const programs = Object.entries( ++ config.External_Programs !== null && typeof config.External_Programs === "object" ++ ? config.External_Programs ++ : {}, ++ ).flatMap(([key, program]: [string, any]) => { ++ const exe = programExe(bottlePath, program?.path); ++ const name = typeof program?.name === "string" ? program.name : undefined; ++ return exe && name ++ ? [ ++ { ++ id: String(program?.id ?? key), ++ name, ++ exe: canonicalPath(exe), ++ exePaths: aliases(exe), ++ }, ++ ] ++ : []; ++ }); ++ found.push({ ++ name: config.Name, ++ path: bottlePath, ++ locations: aliases(location), ++ runner: typeof config.Runner === "string" ? config.Runner : undefined, ++ env: stringEnv(config.Environment_Variables), ++ programs, ++ root, ++ duplicateName: false, ++ }); ++ } ++ for (const bottle of found) { ++ bottle.duplicateName = found.filter((other) => other.name === bottle.name).length > 1; ++ } ++ bottles.push(...found); ++ } ++ return bottles; ++} ++ ++function gameRoot(exe: string, bottlePath: string): string { ++ // GOG games mark their root folder; game extensions expect that one. ++ const stop = isInside(bottlePath, exe) ? bottlePath : path.parse(exe).root; ++ for (let current = path.dirname(exe); isInside(stop, current); current = path.dirname(current)) { ++ try { ++ if (fs.readdirSync(current).some((name) => /^goggame-\d+\.info$/.test(name))) return current; ++ } catch { ++ break; ++ } ++ if (current === stop || current === path.dirname(current)) break; ++ } ++ return path.dirname(exe); ++} ++ ++/** Every program added to a bottle, as a game store entry. */ ++export function readBottlesGames(): BottlesGame[] { ++ return readBottles().flatMap((bottle) => ++ bottle.programs.map((program) => ({ ++ // Bottles has no store IDs; this can never match another store's game. ++ appid: `bottles-${bottle.name}-${program.id}`, ++ name: program.name, ++ gamePath: canonicalPath(gameRoot(program.exe, bottle.path)), ++ gameStoreId: "bottles" as const, ++ bottle, ++ program, ++ })), ++ ); ++} ++ ++/** Bottles that contain the game folder, or run a program from inside it. */ ++export function matchBottles(gamePath: string | undefined): Bottle[] { ++ if (!gamePath) return []; ++ const targets = aliases(gamePath); ++ return readBottles().filter( ++ (bottle) => ++ insideAny(bottle.locations, targets) || ++ bottle.programs.some((program) => insideAny(targets, program.exePaths)), ++ ); ++} ++ ++function selectedBottle(game: CompatibilityGame): Bottle | undefined { ++ const matches = matchBottles(game.path); ++ if (new Set(matches.map((bottle) => bottle.path)).size > 1) ++ throw new Error("Several bottles contain this game. Set WINEPREFIX for it explicitly."); ++ return matches[0]; ++} ++ ++export function bottlesPrefix(game: CompatibilityGame): string | undefined { ++ const bottle = selectedBottle(game); ++ return bottle && fs.existsSync(path.join(bottle.path, "drive_c")) ? bottle.path : undefined; ++} ++ ++/** The program to start: the only one in the selected bottle whose executable is inside the game folder. */ ++export function findBottlesLaunch( ++ game: CompatibilityGame, ++): { bottle: Bottle; program: BottlesProgram } | undefined { ++ if (!game.path) return undefined; ++ const bottle = selectedBottle(game); ++ if (!bottle) return undefined; ++ const targets = aliases(game.path); ++ const programs = bottle.programs.filter((program) => insideAny(targets, program.exePaths)); ++ return programs.length === 1 ? { bottle, program: programs[0] } : undefined; ++} ++ ++export function bottlesCli(flatpak: boolean): { executable: string; args: string[] } | undefined { ++ if (!bottlesRoots().some((root) => root.flatpak === flatpak && fs.existsSync(root.dataPath))) ++ return undefined; ++ const executable = flatpak ++ ? findProgram("flatpak") ++ : findProgram(process.env.BOTTLES_CLI_BINARY ?? "bottles-cli"); ++ return executable ++ ? { executable, args: flatpak ? ["run", "--command=bottles-cli", FLATPAK_ID] : [] } ++ : undefined; ++} ++ ++/** bottles-cli joins arguments into a shell command line, so each needs quoting. */ ++export function shellQuote(value: string): string { ++ return `'${value.replaceAll("'", `'\\''`)}'`; ++} ++ ++export function bottlesLaunchCommand( ++ bottle: Bottle, ++ program: BottlesProgram, ++ args: string[] = [], ++): ToolCommand { ++ if (bottle.duplicateName) ++ throw new Error( ++ `Several bottles are named "${bottle.name}". Rename them in Bottles so Vortex can start the right one.`, ++ ); ++ const cli = bottlesCli(bottle.root.flatpak); ++ if (!cli) ++ throw new Error( ++ "bottles-cli was not found. Install Bottles or set BOTTLES_CLI_BINARY to bottles-cli.", ++ ); ++ return { ++ executable: cli.executable, ++ args: [...cli.args, "run", "-b", bottle.name, "-p", program.name, ...args.map(shellQuote)], ++ options: { shell: false }, ++ }; ++} ++ ++function runnerBinary(bottle: Bottle): string { ++ const runner = bottle.runner; ++ if (!runner) throw new Error(`Select a runner for the "${bottle.name}" bottle in Bottles`); ++ if (runner.startsWith("sys-")) return "wine"; ++ const build = path.join(bottle.root.dataPath, "runners", runner); ++ for (const binary of ["bin/wine", "bin/wine64"]) { ++ if (fs.existsSync(path.join(build, binary))) return path.join(build, binary); ++ } ++ if (/proton/i.test(runner) || fs.existsSync(path.join(build, "proton"))) ++ throw new Error( ++ `Running tools with Bottles' Proton runner (${runner}) is not supported yet. Select a Wine runner for the bottle in Bottles.`, ++ ); ++ throw new Error(`Bottles' runner ${runner} is not installed`); ++} ++ ++/** ++ * Run a Windows tool with the bottle's own runner. bottles-cli would return as ++ * soon as the tool starts, and Vortex has to know when a tool exits. ++ */ ++export async function bottlesToolCommand( ++ command: ToolCommand, ++ selected: CompatibilityGame, ++): Promise { ++ const bottle = selectedBottle(selected); ++ if (!bottle) throw new Error("The selected game is not in a Bottles bottle"); ++ const prefix = command.options.env?.WINEPREFIX ?? bottlesPrefix(selected); ++ if (!prefix || !fs.existsSync(path.join(prefix, "drive_c"))) ++ throw new Error("The bottle's Wine prefix is unavailable"); ++ let executable = runnerBinary(bottle); ++ const env: Record = { ...bottle.env, ...command.options.env, WINEPREFIX: prefix }; ++ let args = [command.executable, ...command.args]; ++ if (bottle.root.flatpak) { ++ const flatpak = findProgram("flatpak"); ++ if (!flatpak) throw new Error("Flatpak was not found"); ++ args = [ ++ "run", ++ `--command=${executable}`, ++ ...Object.entries(env).map(([key, value]) => `--env=${key}=${value}`), ++ FLATPAK_ID, ++ ...args, ++ ]; ++ executable = flatpak; ++ } else if (!findProgram(executable)) throw new Error(`Bottles' runner is missing: ${executable}`); ++ return { executable, args, options: { ...command.options, shell: false, env } }; ++}