Skip to content

Commit 38ea360

Browse files
committed
[bugfix-627] support host calls for flatpak with spawn/exec
* created wrapper calls for spawn and exec for all os support * refactored names for isProcessRunning, getProcessId, and isSteamRunning
1 parent 8096b3e commit 38ea360

12 files changed

+214
-83
lines changed

.eslintrc.js

+1
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ module.exports = {
6060
"jsx-a11y/control-has-associated-label": "off",
6161
"react/button-has-type": "off",
6262
"max-classes-per-file": "off",
63+
"jest/no-standalone-expect": "off",
6364
},
6465
parserOptions: {
6566
ecmaVersion: 2020,

electron-builder.config.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,4 @@ const config = {
9090
],
9191
};
9292

93-
export default config;
93+
module.exports = config;

src/main/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,5 @@ export const HTTP_STATUS_CODES = constants;
2121

2222
export const PROTON_BINARY_PREFIX = "proton";
2323
export const WINE_BINARY_PREFIX = path.join("files", "bin", "wine64");
24+
export const IS_FLATPAK = process.env.container === "flatpak";
2425

src/main/helpers/os.helpers.ts

+151-10
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,166 @@
1+
import cp from "child_process";
12
import log from "electron-log";
23
import psList from "ps-list";
4+
import { IS_FLATPAK } from "main/constants";
35

4-
export async function taskRunning(task: string): Promise<boolean> {
5-
try {
6-
const processes = await psList();
7-
return processes.some(process => process.name?.includes(task) || process.cmd?.includes(task));
6+
// There are 2 erroneous lines ps | grep which is both the ps and grep calls themselves
7+
const MIN_PROCESS_COUNT_LINUX = 2;
8+
9+
type LinuxOptions = {
10+
// Add the prefix to the command
11+
// eg. command - "./Beat Saber.exe" --no-yeet, prefix - "path/to/proton" run
12+
// = "path/to/proton" run "./Beat Saber.exe" --no-yeet
13+
prefix: string;
14+
};
15+
16+
// Only applied if package as flatpak
17+
type FlatpakOptions = {
18+
// Force to use "flatpak-spawn --host" to run commands outside of the sandbox
19+
host: boolean;
20+
// Only copy the keys from options.env from bsmSpawn/bsmExec
21+
env?: string[];
22+
};
23+
24+
export type BsmSpawnOptions = {
25+
args?: string[];
26+
options?: cp.SpawnOptions;
27+
log?: boolean;
28+
linux?: LinuxOptions;
29+
flatpak?: FlatpakOptions;
30+
};
31+
32+
export type BsmExecOptions = {
33+
args?: string[];
34+
options?: cp.ExecOptions;
35+
log?: boolean;
36+
linux?: LinuxOptions;
37+
flatpak?: FlatpakOptions;
38+
};
39+
40+
function updateCommand(command: string, options: BsmSpawnOptions) {
41+
if (options?.args) {
42+
command += ` ${options.args.join(" ")}`;
43+
}
44+
45+
if (process.platform === "linux") {
46+
// "/bin/sh" does not see flatpak-spawn
47+
// Most Debian and Arch should also support "/bin/bash"
48+
options.options.shell = "/bin/bash";
49+
50+
if (options.linux?.prefix) {
51+
command = `${options.linux.prefix} ${command}`;
52+
}
53+
54+
if (options?.flatpak?.host) {
55+
const envArgs = (options?.flatpak?.env && options?.options?.env)
56+
&& options.flatpak.env
57+
.filter(envName => options.options.env[envName])
58+
.map(envName =>
59+
`--env=${envName}="${options.options.env[envName]}"`
60+
)
61+
.join(" ");
62+
command = `flatpak-spawn --host ${envArgs || ""} ${command}`;
63+
}
864
}
9-
catch(error){
65+
66+
return command;
67+
}
68+
69+
export function bsmSpawn(command: string, options?: BsmSpawnOptions) {
70+
options = options || {};
71+
options.options = options.options || {};
72+
command = updateCommand(command, options);
73+
74+
if (options?.log) {
75+
log.info(process.platform === "win32" ? "Windows" : "Linux", "spawn command\n>", command);
76+
}
77+
78+
return cp.spawn(command, options.options);
79+
}
80+
81+
export function bsmExec(command: string, options?: BsmExecOptions): Promise<{
82+
stdout: string;
83+
stderr: string;
84+
}> {
85+
options = options || {};
86+
options.options = options.options || {};
87+
command = updateCommand(command, options);
88+
89+
if (options?.log) {
90+
log.info(
91+
process.platform === "win32" ? "Windows" : "Linux",
92+
"exec command\n>", command
93+
);
94+
}
95+
96+
return new Promise((resolve, reject) => {
97+
cp.exec(command, options?.options || {}, (error: Error, stdout: string, stderr: string) => {
98+
if (error) { return reject(error); }
99+
resolve({ stdout, stderr });
100+
});
101+
})
102+
}
103+
104+
async function isProcessRunningLinux(name: string): Promise<boolean> {
105+
try {
106+
const { stdout: count } = await bsmExec(`ps awwxo args | grep -c "${name}"`, {
107+
log: true,
108+
flatpak: { host: IS_FLATPAK },
109+
});
110+
111+
return +count.trim() > MIN_PROCESS_COUNT_LINUX;
112+
} catch(error) {
10113
log.error(error);
11114
return false;
12-
}
115+
};
13116
}
14117

15-
export async function getProcessPid(task: string): Promise<number> {
118+
async function getProcessIdWindows(name: string): Promise<number | null> {
16119
try {
17120
const processes = await psList();
18-
const process = processes.find(process => process.name?.includes(task) || process.cmd?.includes(task));
121+
const process = processes.find(process => process.name?.includes(name) || process.cmd?.includes(name));
19122
return process?.pid;
20-
}
21-
catch(error){
123+
} catch (error) {
22124
log.error(error);
23125
return null;
24126
}
25127
}
128+
129+
export const isProcessRunning = process.platform === "win32"
130+
? isProcessRunningWindows
131+
: isProcessRunningLinux;
132+
133+
async function isProcessRunningWindows(name: string): Promise<boolean> {
134+
try {
135+
const processes = await psList();
136+
return processes.some(process =>
137+
process.name?.includes(name) || process.cmd?.includes(name)
138+
);
139+
} catch (error) {
140+
log.error(error);
141+
return false;
142+
}
143+
}
144+
145+
async function getProcessIdLinux(name: string): Promise<number | null> {
146+
try {
147+
const { stdout } = await bsmExec(`ps awwxo pid,args | grep "${name}"`, {
148+
log: true,
149+
flatpak: { host: IS_FLATPAK },
150+
});
151+
152+
const line = stdout.split("\n")
153+
.slice(0, -MIN_PROCESS_COUNT_LINUX)
154+
.map(line => line.trimStart())
155+
.find(line => line.includes(name) && !line.includes("grep"));
156+
return line ? +line.split(" ").at(0) : null;
157+
} catch(error) {
158+
log.error(error);
159+
return null;
160+
};
161+
}
162+
163+
export const getProcessId = process.platform === "win32"
164+
? getProcessIdWindows
165+
: getProcessIdLinux;
166+

src/main/services/bs-launcher/abstract-launcher.service.ts

+21-7
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { LaunchOption } from "shared/models/bs-launch";
22
import { BSLocalVersionService } from "../bs-local-version.service";
3-
import { ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio, spawn } from "child_process";
3+
import { ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio } from "child_process";
44
import path from "path";
55
import log from "electron-log";
66
import { sToMs } from "../../../shared/helpers/time.helpers";
77
import { LinuxService } from "../linux.service";
8+
import { bsmSpawn } from "main/helpers/os.helpers";
9+
import { IS_FLATPAK } from "main/constants";
810

911
export abstract class AbstractLauncherService {
1012

@@ -47,12 +49,24 @@ export abstract class AbstractLauncherService {
4749
spawnOptions.windowsVerbatimArguments = true;
4850
}
4951

50-
if (process.platform === "linux") {
51-
return this.linux.spawnBsProcess(bsExePath, args, spawnOptions)
52-
}
53-
54-
log.info("Windows launch BS command\n>" ,bsExePath, args?.join(" "));
55-
return spawn(bsExePath, args, spawnOptions);
52+
return bsmSpawn(`"${bsExePath}"`, {
53+
args, options: spawnOptions, log: true,
54+
linux: { prefix: this.linux.getProtonCommand() },
55+
flatpak: {
56+
host: IS_FLATPAK,
57+
env: [
58+
"SteamAppId",
59+
"SteamOverlayGameId",
60+
"SteamGameId",
61+
"WINEDLLOVERRIDES",
62+
"STEAM_COMPAT_DATA_PATH",
63+
"STEAM_COMPAT_INSTALL_PATH",
64+
"STEAM_COMPAT_CLIENT_INSTALL_PATH",
65+
"STEAM_COMPAT_APP_ID",
66+
"SteamEnv",
67+
],
68+
},
69+
});
5670
}
5771

5872
protected launchBs(bsExePath: string, args: string[], options?: SpawnBsProcessOptions): {process: ChildProcessWithoutNullStreams, exit: Promise<number>} {

src/main/services/bs-launcher/oculus-launcher.service.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import log from "electron-log";
88
import { sToMs } from "../../../shared/helpers/time.helpers";
99
import { lstat, pathExists, readdir, readlink, rename, symlink, unlink } from "fs-extra";
1010
import { AbstractLauncherService } from "./abstract-launcher.service";
11-
import { taskRunning } from "../../helpers/os.helpers";
11+
import { isProcessRunning } from "../../helpers/os.helpers";
1212
import { CustomError } from "../../../shared/models/exceptions/custom-error.class";
1313
import { InstallationLocationService } from "../installation-location.service";
1414
import { ensurePathNotAlreadyExist } from "../../helpers/fs.helpers";
@@ -154,7 +154,7 @@ export class OculusLauncherService extends AbstractLauncherService implements St
154154
(async () => {
155155

156156
// Cannot start multiple instances of Beat Saber with Oculus
157-
const bsRunning = await taskRunning(BS_EXECUTABLE).catch(() => false);
157+
const bsRunning = await isProcessRunning(BS_EXECUTABLE).catch(() => false);
158158
if(bsRunning){
159159
throw CustomError.fromError(new Error("Cannot start two instance of Beat Saber for Oculus"), BSLaunchError.BS_ALREADY_RUNNING);
160160
}

src/main/services/bs-launcher/steam-launcher.service.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export class SteamLauncherService extends AbstractLauncherService implements Sto
7474
}
7575

7676
// Open Steam if not running
77-
if(!(await this.steam.steamRunning())){
77+
if(!(await this.steam.isSteamRunning())){
7878
obs.next({type: BSLaunchEvent.STEAM_LAUNCHING});
7979

8080
await this.steam.openSteam().then(() => {

src/main/services/bs-version-lib.service.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { RequestService } from "./request.service";
66
import { pathExistsSync, readJSON } from "fs-extra";
77
import { allSettled } from "../../shared/helpers/promise.helpers";
88
import { LinuxService } from "./linux.service";
9+
import { IS_FLATPAK } from "main/constants";
910

1011
export class BSVersionLibService {
1112
private readonly REMOTE_BS_VERSIONS_URL: string = "https://raw.githubusercontent.com/Zagrios/bs-manager/master/assets/jsons/bs-versions.json";
@@ -37,7 +38,7 @@ export class BSVersionLibService {
3738
}
3839

3940
private async getLocalVersions(): Promise<BSVersion[]> {
40-
if (this.linuxService.isFlatpak) {
41+
if (IS_FLATPAK) {
4142
const flatpakVersionsPath = path.join(this.linuxService.getFlatpakLocalVersionFolder(), this.VERSIONS_FILE);
4243
if (pathExistsSync(flatpakVersionsPath)) {
4344
return readJSON(flatpakVersionsPath);
@@ -50,7 +51,7 @@ export class BSVersionLibService {
5051

5152
private async updateLocalVersions(versions: BSVersion[]): Promise<void> {
5253
const localVersionsPath = path.join(
53-
this.linuxService.isFlatpak
54+
IS_FLATPAK
5455
? this.linuxService.getFlatpakLocalVersionFolder()
5556
: this.utilsService.getAssestsJsonsPath(),
5657
this.VERSIONS_FILE

src/main/services/linux.service.ts

+11-44
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import fs from "fs-extra";
22
import log from "electron-log";
33
import path from "path";
4-
import { SpawnOptionsWithoutStdio, spawn } from "child_process";
54
import { BS_APP_ID, PROTON_BINARY_PREFIX, WINE_BINARY_PREFIX } from "main/constants";
65
import { StaticConfigurationService } from "./static-configuration.service";
76
import { CustomError } from "shared/models/exceptions/custom-error.class";
@@ -19,8 +18,7 @@ export class LinuxService {
1918
}
2019

2120
private readonly staticConfig: StaticConfigurationService;
22-
23-
public readonly isFlatpak = process.env.container === "flatpak";
21+
private protonCommand = "";
2422

2523
private constructor() {
2624
this.staticConfig = StaticConfigurationService.getInstance();
@@ -55,13 +53,17 @@ export class LinuxService {
5553
BSLaunchError.PROTON_NOT_SET
5654
);
5755
}
58-
const protonPath = path.join(this.staticConfig.get("proton-folder"), PROTON_BINARY_PREFIX);
56+
const protonPath = path.join(
57+
this.staticConfig.get("proton-folder"),
58+
PROTON_BINARY_PREFIX
59+
);
5960
if (!fs.pathExistsSync(protonPath)) {
6061
throw CustomError.fromError(
6162
new Error("Could not locate proton binary"),
6263
BSLaunchError.PROTON_NOT_FOUND
6364
);
6465
}
66+
this.protonCommand = `"${protonPath}" run`;
6567

6668
// Setup Proton environment variables
6769
Object.assign(env, {
@@ -78,22 +80,6 @@ export class LinuxService {
7880
});
7981
}
8082

81-
public spawnBsProcess(bsExePath: string, args: string[], spawnOptions: SpawnOptionsWithoutStdio) {
82-
// Already checked in setupLaunch
83-
const protonPath = path.join(this.staticConfig.get("proton-folder"), PROTON_BINARY_PREFIX);
84-
85-
// "/bin/sh" does not see flatpak-spawn
86-
// Most Debian and Arch should also support "/bin/bash"
87-
spawnOptions.shell = "/bin/bash";
88-
89-
const command = this.isFlatpak
90-
? this.createFlatpakCommand(protonPath, bsExePath, args, spawnOptions)
91-
: `"${protonPath}" run "${bsExePath}" ${args.join(" ")}`;
92-
93-
log.info("Linux launch BS command\n>", command);
94-
return spawn(command, spawnOptions);
95-
}
96-
9783
public verifyProtonPath(protonFolder: string = ""): boolean {
9884
if (protonFolder === "") {
9985
if (!this.staticConfig.has("proton-folder")) {
@@ -124,32 +110,13 @@ export class LinuxService {
124110
return winePath;
125111
}
126112

127-
// === Flatpak Specific === //
128-
129-
private createFlatpakCommand(protonPath: string, bsExePath: string, args: string[], spawnOptions: SpawnOptionsWithoutStdio): string {
130-
131-
// DON'T REMOVE: Good for injecting commands while debugging with flatpak
132-
// return args.slice(1).join(" ");
133-
134-
// The env vars are hidden to flatpak-spawn, need to set them manually in --env arg
135-
// Minimal copy of the env, don't need to copy them all
136-
const envArgs = [
137-
"SteamAppId",
138-
"SteamOverlayGameId",
139-
"SteamGameId",
140-
"WINEDLLOVERRIDES",
141-
"STEAM_COMPAT_DATA_PATH",
142-
"STEAM_COMPAT_INSTALL_PATH",
143-
"STEAM_COMPAT_CLIENT_INSTALL_PATH",
144-
"STEAM_COMPAT_APP_ID",
145-
"SteamEnv",
146-
].map(envName => {
147-
return `--env=${envName}="${spawnOptions.env[envName]}"`;
148-
}).join(" ");
149-
150-
return `flatpak-spawn --host ${envArgs} "${protonPath}" run "${bsExePath}" ${args.join(" ")}`;
113+
public getProtonCommand(): string {
114+
// Set in setupLaunch
115+
return this.protonCommand;
151116
}
152117

118+
// === Flatpak Specific === //
119+
153120
public getFlatpakLocalVersionFolder(): string {
154121
return path.join(
155122
app.getPath("home"),

0 commit comments

Comments
 (0)