mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-14 03:10:01 +00:00
✨ feat: Implement VRChat custom protocol handling
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user