mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 19:08:52 +00:00
🐛 fix: launch created instances through vrchat protocol
This commit is contained in:
+51
-7
@@ -1,6 +1,7 @@
|
|||||||
import { shell } from "electron";
|
import { shell } from "electron";
|
||||||
import { exec, spawn } from "node:child_process";
|
import { exec, spawn } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
import { readFile, readdir } from "node:fs/promises";
|
||||||
import { VRCHAT_APPID, vrchatLaunchExe, vrchatProton, vrchatPrefix, steamRoot } from "./steam";
|
import { VRCHAT_APPID, vrchatLaunchExe, vrchatProton, vrchatPrefix, steamRoot } from "./steam";
|
||||||
import type { GameStatus } from "../../shared/types/game";
|
import type { GameStatus } from "../../shared/types/game";
|
||||||
import { broadcast } from "../windows";
|
import { broadcast } from "../windows";
|
||||||
@@ -16,8 +17,12 @@ async function isRunning(): Promise<boolean> {
|
|||||||
const { stdout } = await sh('tasklist /fi "imagename eq VRChat.exe" /nh');
|
const { stdout } = await sh('tasklist /fi "imagename eq VRChat.exe" /nh');
|
||||||
return /vrchat\.exe/i.test(stdout);
|
return /vrchat\.exe/i.test(stdout);
|
||||||
}
|
}
|
||||||
const { stdout } = await sh("ps -A -o args=");
|
const { stdout } = await sh("ps -A -o stat=,args=");
|
||||||
return stdout.split("\n").some((line) => /vrchat\.exe/i.test(line) && !/grep/i.test(line));
|
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 {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -70,20 +75,26 @@ export async function joinInstance(url: string): Promise<JoinResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (running) {
|
if (running) {
|
||||||
linuxHandoff(url);
|
await linuxHandoff(url);
|
||||||
await focus();
|
await focus();
|
||||||
return { launched: true, alreadyRunning: true };
|
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 });
|
broadcast("game:changed", { running: false, supported: true, launching: true });
|
||||||
spawn("steam", ["-applaunch", VRCHAT_APPID, url], { detached: true, stdio: "ignore" }).unref();
|
spawn("steam", ["-applaunch", VRCHAT_APPID, url], { detached: true, stdio: "ignore" }).unref();
|
||||||
logger.info("game", "cold join via steam -applaunch");
|
logger.info("game", "cold join via steam -applaunch");
|
||||||
return { launched: true, alreadyRunning: false };
|
return { launched: true, alreadyRunning: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
// reaches a live VRChat through its named pipe by running launch.exe inside the
|
async function linuxHandoff(url: string): Promise<void> {
|
||||||
// same proton prefix; steam -applaunch is a no-op once the game is up.
|
|
||||||
function linuxHandoff(url: string): void {
|
|
||||||
const proton = vrchatProton();
|
const proton = vrchatProton();
|
||||||
const exe = vrchatLaunchExe();
|
const exe = vrchatLaunchExe();
|
||||||
const prefix = vrchatPrefix();
|
const prefix = vrchatPrefix();
|
||||||
@@ -95,11 +106,18 @@ function linuxHandoff(url: string): void {
|
|||||||
});
|
});
|
||||||
return;
|
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], {
|
spawn(proton, ["run", exe, url], {
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
env: {
|
env: {
|
||||||
...process.env,
|
...inherited,
|
||||||
STEAM_COMPAT_DATA_PATH: prefix,
|
STEAM_COMPAT_DATA_PATH: prefix,
|
||||||
STEAM_COMPAT_CLIENT_INSTALL_PATH: steamRoot(),
|
STEAM_COMPAT_CLIENT_INSTALL_PATH: steamRoot(),
|
||||||
},
|
},
|
||||||
@@ -107,9 +125,35 @@ function linuxHandoff(url: string): void {
|
|||||||
logger.info("game", "warm join via proton launch.exe");
|
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 lastRunning = false;
|
||||||
|
let coldStartInFlight = false;
|
||||||
|
|
||||||
function setRunning(running: boolean): GameStatus {
|
function setRunning(running: boolean): GameStatus {
|
||||||
|
if (running) coldStartInFlight = false;
|
||||||
if (running !== lastRunning) {
|
if (running !== lastRunning) {
|
||||||
lastRunning = running;
|
lastRunning = running;
|
||||||
broadcast("game:changed", { running, supported: true });
|
broadcast("game:changed", { running, supported: true });
|
||||||
|
|||||||
@@ -153,9 +153,15 @@ const handlers = {
|
|||||||
|
|
||||||
"game:status": () => guard(() => game.status()),
|
"game:status": () => guard(() => game.status()),
|
||||||
"game:launch": () => guard(() => game.launch()),
|
"game:launch": () => guard(() => game.launch()),
|
||||||
"game:join": ({ location }) =>
|
"game:join": ({ location, shortName }) =>
|
||||||
guard(async () => {
|
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()),
|
"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()}`;
|
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({
|
export function CreateInstanceModal({
|
||||||
worldId,
|
worldId,
|
||||||
open,
|
open,
|
||||||
@@ -195,7 +200,7 @@ function ResultView({ instance }: { instance: Instance }) {
|
|||||||
if (!running) markLaunching();
|
if (!running) markLaunching();
|
||||||
setLaunchError(null);
|
setLaunchError(null);
|
||||||
try {
|
try {
|
||||||
await api.game.join(instance.location);
|
await api.game.openProtocol(protocolLink(instance));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setLaunchError(errorMessage(err, "Failed to launch VRChat"));
|
setLaunchError(errorMessage(err, "Failed to launch VRChat"));
|
||||||
}
|
}
|
||||||
@@ -219,10 +224,10 @@ function ResultView({ instance }: { instance: Instance }) {
|
|||||||
|
|
||||||
{canLaunch ? (
|
{canLaunch ? (
|
||||||
<div className="flex flex-col gap-1.5">
|
<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}
|
{!launching ? <Play size={15} /> : null}
|
||||||
{running
|
{running
|
||||||
? t("world:create.running")
|
? t("world:create.join")
|
||||||
: launching
|
: launching
|
||||||
? t("world:create.launching")
|
? t("world:create.launching")
|
||||||
: t("world:create.launch")}
|
: t("world:create.launch")}
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ function JoinActions({ instance }: { instance: Instance }) {
|
|||||||
if (!running) markLaunching();
|
if (!running) markLaunching();
|
||||||
setJoinError(null);
|
setJoinError(null);
|
||||||
try {
|
try {
|
||||||
await api.game.join(instance.location);
|
await api.game.join(instance.location, instance.shortName);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setJoinError(errorMessage(err, "Failed to launch VRChat"));
|
setJoinError(errorMessage(err, "Failed to launch VRChat"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -162,7 +162,8 @@ export const api = {
|
|||||||
game: {
|
game: {
|
||||||
status: () => call("game:status"),
|
status: () => call("game:status"),
|
||||||
launch: () => call("game:launch"),
|
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: {
|
gallery: {
|
||||||
snapshot: () => call("gallery:snapshot"),
|
snapshot: () => call("gallery:snapshot"),
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
"ready": "Instance created.",
|
"ready": "Instance created.",
|
||||||
"launch": "Launch VRChat",
|
"launch": "Launch VRChat",
|
||||||
"launching": "Launching…",
|
"launching": "Launching…",
|
||||||
"running": "VRChat Running…",
|
"join": "Join in VRChat",
|
||||||
"selfInvite": "Invite myself",
|
"selfInvite": "Invite myself",
|
||||||
"selfInviteSent": "Invite sent. Check VRChat.",
|
"selfInviteSent": "Invite sent. Check VRChat.",
|
||||||
"lockedLink": "locked link",
|
"lockedLink": "locked link",
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
"ready": "インスタンスを作成しました。",
|
"ready": "インスタンスを作成しました。",
|
||||||
"launch": "VRChatを起動",
|
"launch": "VRChatを起動",
|
||||||
"launching": "起動中…",
|
"launching": "起動中…",
|
||||||
"running": "VRChat実行中…",
|
"join": "VRChatで参加",
|
||||||
"selfInvite": "自分を招待",
|
"selfInvite": "自分を招待",
|
||||||
"selfInviteSent": "招待を送信しました。VRChatを確認してください",
|
"selfInviteSent": "招待を送信しました。VRChatを確認してください",
|
||||||
"lockedLink": "ロック付きリンク",
|
"lockedLink": "ロック付きリンク",
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
"ready": "สร้างอินสแตนซ์แล้ว",
|
"ready": "สร้างอินสแตนซ์แล้ว",
|
||||||
"launch": "เปิด VRChat",
|
"launch": "เปิด VRChat",
|
||||||
"launching": "กำลังเปิด…",
|
"launching": "กำลังเปิด…",
|
||||||
"running": "VRChat กำลังทำงาน…",
|
"join": "เข้าร่วมใน VRChat",
|
||||||
"selfInvite": "เชิญตัวเอง",
|
"selfInvite": "เชิญตัวเอง",
|
||||||
"selfInviteSent": "ส่งคำเชิญแล้ว ตรวจสอบใน VRChat",
|
"selfInviteSent": "ส่งคำเชิญแล้ว ตรวจสอบใน VRChat",
|
||||||
"lockedLink": "ลิงก์ที่ล็อก",
|
"lockedLink": "ลิงก์ที่ล็อก",
|
||||||
|
|||||||
+2
-1
@@ -159,7 +159,8 @@ export interface IpcRequests {
|
|||||||
|
|
||||||
"game:status": () => IpcResult<GameStatus>;
|
"game:status": () => IpcResult<GameStatus>;
|
||||||
"game:launch": () => 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:snapshot": () => IpcResult<GallerySnapshot>;
|
||||||
"gallery:reveal": (path: string) => IpcResult<void>;
|
"gallery:reveal": (path: string) => IpcResult<void>;
|
||||||
|
|||||||
Reference in New Issue
Block a user