mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 10:58:59 +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();
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { AlertTriangle, Camera } from "lucide-react";
|
||||
import { AlertTriangle, Camera, Link2 } from "lucide-react";
|
||||
import type {
|
||||
EnhancementId,
|
||||
EnhancementState,
|
||||
@@ -24,6 +24,11 @@ const CATALOG: Meta[] = [
|
||||
icon: <Camera size={18} />,
|
||||
platforms: ["linux"],
|
||||
},
|
||||
{
|
||||
id: "vrchat-protocol-handler",
|
||||
icon: <Link2 size={18} />,
|
||||
platforms: ["linux", "win32"], // TODO: add darwin with the in-app instance page
|
||||
},
|
||||
];
|
||||
|
||||
const OS_LABEL: Record<OsPlatform, string> = {
|
||||
@@ -117,7 +122,7 @@ function EnhancementCard({
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-[15px] font-bold">{t(`enhancements:items.${meta.id}.title`)}</h2>
|
||||
{meta.platforms.map((p) => (
|
||||
<Badge key={p} tone={p === platform ? "neutral" : "warn"}>
|
||||
<Badge key={p} tone={supported ? "neutral" : "warn"}>
|
||||
{OS_LABEL[p]}
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
@@ -15,7 +15,10 @@ export function LaunchButton() {
|
||||
setRunning(s.running);
|
||||
setSupported(s.supported);
|
||||
});
|
||||
return events.on("game:changed", (s) => setRunning(s.running));
|
||||
return events.on("game:changed", (s) => {
|
||||
setRunning(s.running);
|
||||
if (s.launching) setLaunching(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -13,6 +13,14 @@
|
||||
"protonFolder": "Proton folder: {{path}}",
|
||||
"noPrefix": "No VRChat Proton prefix found."
|
||||
}
|
||||
},
|
||||
"vrchat-protocol-handler": {
|
||||
"title": "Handle vrchat:// links",
|
||||
"blurb": "Opens vrchat:// invite links through VRC Circle and takes you into the instance, whether VRChat is already running or needs to launch. On Linux it also bridges the link across Proton, which the desktop can't do on its own.",
|
||||
"detail": {
|
||||
"registered": "VRC Circle is the vrchat:// handler.",
|
||||
"notRegistered": "Not registered yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,14 @@
|
||||
"protonFolder": "Proton フォルダ:{{path}}",
|
||||
"noPrefix": "VRChat の Proton プレフィックスが見つかりません。"
|
||||
}
|
||||
},
|
||||
"vrchat-protocol-handler": {
|
||||
"title": "vrchat:// リンクを処理",
|
||||
"blurb": "vrchat:// の招待リンクを VRC Circle で開き、VRChat が起動中でも未起動でもそのインスタンスへ案内します。Linux ではデスクトップ単体ではできない Proton へのリンク転送も行います。",
|
||||
"detail": {
|
||||
"registered": "VRC Circle が vrchat:// のハンドラーです。",
|
||||
"notRegistered": "まだ登録されていません。"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,14 @@
|
||||
"protonFolder": "โฟลเดอร์ Proton: {{path}}",
|
||||
"noPrefix": "ไม่พบ Proton prefix ของ VRChat"
|
||||
}
|
||||
},
|
||||
"vrchat-protocol-handler": {
|
||||
"title": "จัดการลิงก์ vrchat://",
|
||||
"blurb": "เปิดลิงก์เชิญ vrchat:// ผ่าน VRC Circle และพาคุณเข้าสู่อินสแตนซ์ ไม่ว่า VRChat จะเปิดอยู่แล้วหรือยังไม่ได้เปิด บน Linux ยังส่งต่อลิงก์ผ่าน Proton ให้ด้วย ซึ่งเดสก์ท็อปทำเองไม่ได้",
|
||||
"detail": {
|
||||
"registered": "VRC Circle เป็นตัวจัดการ vrchat:// อยู่",
|
||||
"notRegistered": "ยังไม่ได้ลงทะเบียน"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type EnhancementId = "linux-screenshot-symlink";
|
||||
export type EnhancementId = "linux-screenshot-symlink" | "vrchat-protocol-handler";
|
||||
|
||||
export type OsPlatform = "linux" | "win32" | "darwin";
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export interface GameStatus {
|
||||
running: boolean;
|
||||
supported: boolean;
|
||||
launching?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user