feat: Implement VRChat custom protocol handling

This commit is contained in:
2026-06-28 21:14:38 +07:00
parent 851b696f67
commit 23748b15ae
12 changed files with 268 additions and 23 deletions
+39 -15
View File
@@ -10,6 +10,7 @@ import type {
OsPlatform,
} from "../../shared/types/enhancements";
import * as screenshot from "./screenshotSymlink";
import * as protocol from "./vrchatProtocol";
const platform = () => process.platform as OsPlatform;
const storePath = () => join(app.getPath("userData"), "enhancements.json");
@@ -38,32 +39,55 @@ function screenshotState(): EnhancementState {
};
}
export function snapshot(): EnhancementsSnapshot {
return { platform: platform(), states: [screenshotState()] };
function protocolState(): EnhancementState {
const s = protocol.status();
return { id: "vrchat-protocol-handler", enabled: s.registered, detail: s.detail };
}
export function setEnabled(id: EnhancementId, enabled: boolean): EnhancementsSnapshot {
export function snapshot(): EnhancementsSnapshot {
return { platform: platform(), states: [screenshotState(), protocolState()] };
}
export async function setEnabled(
id: EnhancementId,
enabled: boolean,
): Promise<EnhancementsSnapshot> {
if (id === "linux-screenshot-symlink") {
if (platform() !== "linux") throw new Error("This enhancement only applies to Linux.");
const status = enabled ? screenshot.enable() : screenshot.disable();
const prefs = readPrefs();
prefs[id] = enabled;
writePrefs(prefs);
logger.info("enhancements", `screenshot symlink ${enabled ? "enabled" : "disabled"}`, status);
} else if (id === "vrchat-protocol-handler") {
const status = enabled ? await protocol.enable() : await protocol.disable();
logger.info("enhancements", `vrchat protocol ${enabled ? "registered" : "removed"}`, status);
}
const prefs = readPrefs();
prefs[id] = enabled;
writePrefs(prefs);
return snapshot();
}
export function reconcile(): void {
if (platform() !== "linux") return;
export async function reconcile(): Promise<void> {
const prefs = readPrefs();
if (!prefs["linux-screenshot-symlink"]) return;
try {
if (!screenshot.status().active) {
screenshot.enable();
logger.info("enhancements", "re-applied screenshot symlink on startup");
if (platform() === "linux" && prefs["linux-screenshot-symlink"]) {
try {
if (!screenshot.status().active) {
screenshot.enable();
logger.info("enhancements", "re-applied screenshot symlink on startup");
}
} catch (err) {
logger.warn("enhancements", "could not re-apply screenshot symlink", err);
}
}
if (prefs["vrchat-protocol-handler"]) {
try {
if (!protocol.status().registered) {
await protocol.enable();
logger.info("enhancements", "re-registered vrchat protocol on startup");
}
} catch (err) {
logger.warn("enhancements", "could not re-register vrchat protocol", err);
}
} catch (err) {
logger.warn("enhancements", "could not re-apply screenshot symlink", err);
}
}
+75
View File
@@ -0,0 +1,75 @@
import { app } from "electron";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { writeFileAtomicSync } from "../lib/atomicFile";
import type { EnhancementDetail } from "../../shared/types/enhancements";
const sh = promisify(exec);
const SCHEME = "vrchat";
const DESKTOP_DIR = join(homedir(), ".local", "share", "applications");
const DESKTOP_FILE = join(DESKTOP_DIR, "vrc-circle-vrchat-url.desktop");
const DESKTOP_NAME = "vrc-circle-vrchat-url.desktop";
export interface ProtocolStatus {
registered: boolean;
detail: EnhancementDetail;
}
function execPath(): string {
// dev's argv[1] is relative; the DE launches from a different cwd so resolve it
const exe = `"${process.execPath}"`;
if (process.defaultApp && process.argv[1]) return `${exe} "${resolve(process.argv[1])}"`;
return exe;
}
export function status(): ProtocolStatus {
if (process.platform !== "linux") {
const registered = app.isDefaultProtocolClient(SCHEME);
return {
registered,
detail: { key: registered ? "registered" : "notRegistered" },
};
}
return {
registered: existsSync(DESKTOP_FILE),
detail: existsSync(DESKTOP_FILE) ? { key: "registered" } : { key: "notRegistered" },
};
}
export async function enable(): Promise<ProtocolStatus> {
if (process.platform !== "linux") {
app.setAsDefaultProtocolClient(SCHEME);
return status();
}
mkdirSync(DESKTOP_DIR, { recursive: true });
const desktop = [
"[Desktop Entry]",
"Type=Application",
"Name=VRC Circle (VRChat link handler)",
`Exec=${execPath()} %u`,
"Terminal=false",
"NoDisplay=true",
`MimeType=x-scheme-handler/${SCHEME};`,
"",
].join("\n");
writeFileAtomicSync(DESKTOP_FILE, desktop);
await sh(`update-desktop-database "${DESKTOP_DIR}"`).catch(() => {});
await sh(`xdg-mime default ${DESKTOP_NAME} x-scheme-handler/${SCHEME}`).catch(() => {});
return status();
}
export async function disable(): Promise<ProtocolStatus> {
if (process.platform !== "linux") {
app.removeAsDefaultProtocolClient(SCHEME);
return status();
}
rmSync(DESKTOP_FILE, { force: true });
await sh(`update-desktop-database "${DESKTOP_DIR}"`).catch(() => {});
return status();
}
+66 -2
View File
@@ -1,9 +1,10 @@
import { shell } from "electron";
import { exec } from "node:child_process";
import { exec, spawn } from "node:child_process";
import { promisify } from "node:util";
import { VRCHAT_APPID } from "./steam";
import { VRCHAT_APPID, vrchatLaunchExe, vrchatProton, vrchatPrefix, steamRoot } from "./steam";
import type { GameStatus } from "../../shared/types/game";
import { broadcast } from "../windows";
import { logger } from "../debug/logger";
const sh = promisify(exec);
@@ -44,6 +45,69 @@ export async function launch(): Promise<GameStatus> {
return { running: lastRunning, supported: true };
}
export interface JoinResult {
launched: boolean;
alreadyRunning: boolean;
unsupported?: boolean;
}
export async function joinInstance(url: string): Promise<JoinResult> {
if (!url.startsWith("vrchat://")) throw new Error("Not a vrchat:// link");
const running = await isRunning();
if (process.platform === "darwin") {
// TODO: no VRChat on macOS; show the instance in an in-app page instead
return { launched: false, alreadyRunning: false, unsupported: true };
}
if (process.platform === "win32") {
if (running) await focus();
else {
broadcast("game:changed", { running: false, supported: true, launching: true });
await shell.openExternal(`steam://rungameid/${VRCHAT_APPID}`);
}
await shell.openExternal(url);
return { launched: true, alreadyRunning: running };
}
if (running) {
linuxHandoff(url);
await focus();
return { launched: true, alreadyRunning: true };
}
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 {
const proton = vrchatProton();
const exe = vrchatLaunchExe();
const prefix = vrchatPrefix();
if (!proton || !exe || !prefix) {
logger.warn("game", "cannot hand off url; proton/launch.exe/prefix not found", {
proton,
exe,
prefix,
});
return;
}
spawn(proton, ["run", exe, url], {
detached: true,
stdio: "ignore",
env: {
...process.env,
STEAM_COMPAT_DATA_PATH: prefix,
STEAM_COMPAT_CLIENT_INSTALL_PATH: steamRoot(),
},
}).unref();
logger.info("game", "warm join via proton launch.exe");
}
let lastRunning = false;
function setRunning(running: boolean): GameStatus {
+28
View File
@@ -53,6 +53,12 @@ export function steamLibraries(): string[] {
return [...libs];
}
export function steamRoot(): string {
const prefix = vrchatPrefix();
if (prefix) return dirname(dirname(dirname(prefix)));
return platformBases()[0];
}
export function vrchatPrefix(): string | null {
for (const lib of steamLibraries()) {
const prefix = join(lib, "compatdata", VRCHAT_APPID);
@@ -61,6 +67,28 @@ export function vrchatPrefix(): string | null {
return null;
}
export function vrchatLaunchExe(): string | null {
for (const lib of steamLibraries()) {
const exe = join(lib, "common", "VRChat", "launch.exe");
if (existsSync(exe)) return exe;
}
return null;
}
export function vrchatProton(): string | null {
const prefix = vrchatPrefix();
if (!prefix) return null;
try {
const info = readFileSync(join(prefix, "config_info"), "utf8").split("\n");
const toolDir = info[1]?.split("/files/")[0]?.trim();
if (toolDir) {
const proton = join(toolDir, "proton");
if (existsSync(proton)) return proton;
}
} catch {}
return null;
}
export function detectedGamePath(): string | null {
for (const lib of steamLibraries()) {
const manifest = join(lib, `appmanifest_${VRCHAT_APPID}.acf`);
+23 -2
View File
@@ -4,7 +4,7 @@ import { registerIpcHandlers } from "./ipc/handlers";
import { reconcile as reconcileEnhancements } from "./enhancements/service";
import { registerGalleryScheme, registerGalleryProtocol } from "./gallery/protocol";
import { startSocialBridge } from "./store/social";
import { startWatcher as startGameWatcher } from "./game/launch";
import { startWatcher as startGameWatcher, joinInstance } from "./game/launch";
import { startGalleryWatch, stopGalleryWatch } from "./gallery/watcher";
import { startDebugBridge } from "./debug/bridge";
import { logger } from "./debug/logger";
@@ -14,10 +14,29 @@ import { activeId } from "./accounts/store";
import { closeClients } from "./vrchat/client";
import { createMainWindow, focusMainWindow } from "./windows";
function handleVrchatUrl(url: string | undefined): void {
if (!url || !url.startsWith("vrchat://")) return;
logger.info("game", "received vrchat:// url", { url });
joinInstance(url).catch((err) => logger.warn("game", "join from protocol url failed", err));
}
function vrchatUrlFromArgv(argv: string[]): string | undefined {
return argv.find((a) => a.startsWith("vrchat://"));
}
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on("second-instance", () => focusMainWindow());
// already-running case (win32/linux): the new instance's argv carries the url
app.on("second-instance", (_e, argv) => {
focusMainWindow();
handleVrchatUrl(vrchatUrlFromArgv(argv));
});
// macOS delivers protocol urls here, both cold and warm
app.on("open-url", (_e, url) => {
focusMainWindow();
handleVrchatUrl(url);
});
registerGalleryScheme();
start();
}
@@ -56,6 +75,8 @@ function start(): void {
startGalleryWatch();
createMainWindow();
handleVrchatUrl(vrchatUrlFromArgv(process.argv));
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
});