🐛 fix: launch created instances through vrchat protocol

This commit is contained in:
2026-06-30 20:44:50 +07:00
parent 7151a3ac90
commit 11d92572e4
9 changed files with 75 additions and 18 deletions
+51 -7
View File
@@ -1,6 +1,7 @@
import { shell } from "electron";
import { exec, spawn } from "node:child_process";
import { promisify } from "node:util";
import { readFile, readdir } from "node:fs/promises";
import { VRCHAT_APPID, vrchatLaunchExe, vrchatProton, vrchatPrefix, steamRoot } from "./steam";
import type { GameStatus } from "../../shared/types/game";
import { broadcast } from "../windows";
@@ -16,8 +17,12 @@ async function isRunning(): Promise<boolean> {
const { stdout } = await sh('tasklist /fi "imagename eq VRChat.exe" /nh');
return /vrchat\.exe/i.test(stdout);
}
const { stdout } = await sh("ps -A -o args=");
return stdout.split("\n").some((line) => /vrchat\.exe/i.test(line) && !/grep/i.test(line));
const { stdout } = await sh("ps -A -o stat=,args=");
return stdout.split("\n").some((line) => {
if (!/vrchat\.exe/i.test(line) || /grep/i.test(line)) return false;
const stat = line.trim().split(/\s+/, 1)[0] ?? "";
return !stat.startsWith("Z");
});
} catch {
return false;
}
@@ -70,20 +75,26 @@ export async function joinInstance(url: string): Promise<JoinResult> {
}
if (running) {
linuxHandoff(url);
await linuxHandoff(url);
await focus();
return { launched: true, alreadyRunning: true };
}
if (coldStartInFlight) {
await focus();
return { launched: false, alreadyRunning: true };
}
coldStartInFlight = true;
setTimeout(() => {
coldStartInFlight = false;
}, 90_000).unref();
broadcast("game:changed", { running: false, supported: true, launching: true });
spawn("steam", ["-applaunch", VRCHAT_APPID, url], { detached: true, stdio: "ignore" }).unref();
logger.info("game", "cold join via steam -applaunch");
return { launched: true, alreadyRunning: false };
}
// reaches a live VRChat through its named pipe by running launch.exe inside the
// same proton prefix; steam -applaunch is a no-op once the game is up.
function linuxHandoff(url: string): void {
async function linuxHandoff(url: string): Promise<void> {
const proton = vrchatProton();
const exe = vrchatLaunchExe();
const prefix = vrchatPrefix();
@@ -95,11 +106,18 @@ function linuxHandoff(url: string): void {
});
return;
}
const inherited = await vrchatGameEnv();
if (!inherited) {
logger.warn("game", "cannot read running VRChat env; skipping warm join to avoid a duplicate");
return;
}
spawn(proton, ["run", exe, url], {
detached: true,
stdio: "ignore",
env: {
...process.env,
...inherited,
STEAM_COMPAT_DATA_PATH: prefix,
STEAM_COMPAT_CLIENT_INSTALL_PATH: steamRoot(),
},
@@ -107,9 +125,35 @@ function linuxHandoff(url: string): void {
logger.info("game", "warm join via proton launch.exe");
}
async function vrchatGameEnv(): Promise<NodeJS.ProcessEnv | null> {
let pids: string[];
try {
pids = (await readdir("/proc")).filter((p) => /^\d+$/.test(p));
} catch {
return null;
}
for (const pid of pids) {
try {
const cmd = await readFile(`/proc/${pid}/cmdline`, "utf8");
if (!/VRChat\.exe/i.test(cmd)) continue;
const raw = await readFile(`/proc/${pid}/environ`, "utf8");
const env: NodeJS.ProcessEnv = {};
for (const pair of raw.split("\0")) {
const eq = pair.indexOf("=");
if (eq < 0) continue;
env[pair.slice(0, eq)] = pair.slice(eq + 1);
}
return env;
} catch {}
}
return null;
}
let lastRunning = false;
let coldStartInFlight = false;
function setRunning(running: boolean): GameStatus {
if (running) coldStartInFlight = false;
if (running !== lastRunning) {
lastRunning = running;
broadcast("game:changed", { running, supported: true });
+8 -2
View File
@@ -153,9 +153,15 @@ const handlers = {
"game:status": () => guard(() => game.status()),
"game:launch": () => guard(() => game.launch()),
"game:join": ({ location }) =>
"game:join": ({ location, shortName }) =>
guard(async () => {
await game.joinInstance(`vrchat://launch?ref=vrchat.com&id=${location}`);
const suffix = shortName ? `&shortName=${encodeURIComponent(shortName)}` : "";
await game.joinInstance(`vrchat://launch?ref=vrchat.com&id=${location}${suffix}&attach=1`);
}),
"game:openProtocol": (url) =>
guard(async () => {
if (!url.startsWith("vrchat://")) throw new Error("Invalid VRChat link");
await shell.openExternal(url);
}),
"gallery:snapshot": () => guard(async () => gallery.snapshot()),
@@ -22,6 +22,11 @@ function launchLink(inst: Instance, name: string): string {
return `https://vrchat.com/home/launch?${params.toString()}`;
}
function protocolLink(inst: Instance): string {
const shortName = inst.shortName ? `&shortName=${encodeURIComponent(inst.shortName)}` : "";
return `vrchat://launch?ref=vrchat.com&id=${inst.location}${shortName}&attach=1`;
}
export function CreateInstanceModal({
worldId,
open,
@@ -195,7 +200,7 @@ function ResultView({ instance }: { instance: Instance }) {
if (!running) markLaunching();
setLaunchError(null);
try {
await api.game.join(instance.location);
await api.game.openProtocol(protocolLink(instance));
} catch (err) {
setLaunchError(errorMessage(err, "Failed to launch VRChat"));
}
@@ -219,10 +224,10 @@ function ResultView({ instance }: { instance: Instance }) {
{canLaunch ? (
<div className="flex flex-col gap-1.5">
<Button variant="primary" onClick={launch} loading={launching} disabled={running} block>
<Button variant="primary" onClick={launch} loading={launching} block>
{!launching ? <Play size={15} /> : null}
{running
? t("world:create.running")
? t("world:create.join")
: launching
? t("world:create.launching")
: t("world:create.launch")}
@@ -141,7 +141,7 @@ function JoinActions({ instance }: { instance: Instance }) {
if (!running) markLaunching();
setJoinError(null);
try {
await api.game.join(instance.location);
await api.game.join(instance.location, instance.shortName);
} catch (err) {
setJoinError(errorMessage(err, "Failed to launch VRChat"));
}
+2 -1
View File
@@ -162,7 +162,8 @@ export const api = {
game: {
status: () => call("game:status"),
launch: () => call("game:launch"),
join: (location: string) => call("game:join", { location }),
join: (location: string, shortName?: string | null) => call("game:join", { location, shortName }),
openProtocol: (url: string) => call("game:openProtocol", url),
},
gallery: {
snapshot: () => call("gallery:snapshot"),
@@ -83,7 +83,7 @@
"ready": "Instance created.",
"launch": "Launch VRChat",
"launching": "Launching…",
"running": "VRChat Running…",
"join": "Join in VRChat",
"selfInvite": "Invite myself",
"selfInviteSent": "Invite sent. Check VRChat.",
"lockedLink": "locked link",
@@ -83,7 +83,7 @@
"ready": "インスタンスを作成しました。",
"launch": "VRChatを起動",
"launching": "起動中…",
"running": "VRChat実行中…",
"join": "VRChatで参加",
"selfInvite": "自分を招待",
"selfInviteSent": "招待を送信しました。VRChatを確認してください",
"lockedLink": "ロック付きリンク",
@@ -83,7 +83,7 @@
"ready": "สร้างอินสแตนซ์แล้ว",
"launch": "เปิด VRChat",
"launching": "กำลังเปิด…",
"running": "VRChat กำลังทำงาน…",
"join": "เข้าร่วมใน VRChat",
"selfInvite": "เชิญตัวเอง",
"selfInviteSent": "ส่งคำเชิญแล้ว ตรวจสอบใน VRChat",
"lockedLink": "ลิงก์ที่ล็อก",
+2 -1
View File
@@ -159,7 +159,8 @@ export interface IpcRequests {
"game:status": () => IpcResult<GameStatus>;
"game:launch": () => IpcResult<GameStatus>;
"game:join": (p: { location: string }) => IpcResult<void>;
"game:join": (p: { location: string; shortName?: string | null }) => IpcResult<void>;
"game:openProtocol": (url: string) => IpcResult<void>;
"gallery:snapshot": () => IpcResult<GallerySnapshot>;
"gallery:reveal": (path: string) => IpcResult<void>;