feat: Implement VRChat protocol handling for macOS

This commit is contained in:
2026-06-29 01:42:06 +07:00
parent 42db5d9946
commit 716fb82ae1
12 changed files with 126 additions and 32 deletions
+3
View File
@@ -14,3 +14,6 @@ win:
mac:
target: [dmg]
category: public.app-category.social-networking
protocols:
- name: VRChat
schemes: [vrchat]
+1
View File
@@ -18,6 +18,7 @@
"format": "prettier --check .",
"format:fix": "prettier --write .",
"package": "pnpm run build && electron-builder --dir",
"package:run": "pnpm run package && open dist/mac*/'VRC Circle.app'",
"dist": "pnpm run build && electron-builder"
},
"dependencies": {
+14
View File
@@ -41,6 +41,16 @@ function screenshotState(): EnhancementState {
function protocolState(): EnhancementState {
const s = protocol.status();
// isDefaultProtocolClient stays true after disabling on macOS/Windows; trust the pref instead
if (platform() !== "linux") {
const pref = readPrefs()["vrchat-protocol-handler"];
const enabled = pref ?? s.registered;
return {
id: "vrchat-protocol-handler",
enabled,
detail: { key: enabled ? "registered" : "notRegistered" },
};
}
return { id: "vrchat-protocol-handler", enabled: s.registered, detail: s.detail };
}
@@ -48,6 +58,10 @@ export function snapshot(): EnhancementsSnapshot {
return { platform: platform(), states: [screenshotState(), protocolState()] };
}
export function isEnabled(id: EnhancementId): boolean {
return readPrefs()[id] ?? false;
}
export async function setEnabled(
id: EnhancementId,
enabled: boolean,
+30 -2
View File
@@ -5,11 +5,15 @@ import { exec } from "node:child_process";
import { promisify } from "node:util";
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { writeFileAtomicSync } from "../lib/atomicFile";
import { logger } from "../debug/logger";
import type { EnhancementDetail } from "../../shared/types/enhancements";
const sh = promisify(exec);
const SCHEME = "vrchat";
// macOS keys the scheme off the bundled Info.plist, so dev has nothing to register or remove
const isMacDev = process.platform === "darwin" && process.defaultApp;
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";
@@ -27,6 +31,7 @@ function execPath(): string {
}
export function status(): ProtocolStatus {
if (isMacDev) return { registered: false, detail: { key: "notRegistered" } };
if (process.platform !== "linux") {
const registered = app.isDefaultProtocolClient(SCHEME);
return {
@@ -41,8 +46,20 @@ export function status(): ProtocolStatus {
}
export async function enable(): Promise<ProtocolStatus> {
if (isMacDev) {
logger.info(
"enhancements",
"vrchat protocol registration skipped; macOS dev needs a packaged build",
);
return status();
}
if (process.platform !== "linux") {
app.setAsDefaultProtocolClient(SCHEME);
// in dev the bare electron binary has no project context, so pass the entry script
if (process.defaultApp && process.argv[1]) {
app.setAsDefaultProtocolClient(SCHEME, process.execPath, [resolve(process.argv[1])]);
} else {
app.setAsDefaultProtocolClient(SCHEME);
}
return status();
}
@@ -65,8 +82,19 @@ export async function enable(): Promise<ProtocolStatus> {
}
export async function disable(): Promise<ProtocolStatus> {
if (isMacDev) {
logger.info(
"enhancements",
"vrchat protocol removal skipped; macOS dev has nothing registered",
);
return status();
}
if (process.platform !== "linux") {
app.removeAsDefaultProtocolClient(SCHEME);
if (process.defaultApp && process.argv[1]) {
app.removeAsDefaultProtocolClient(SCHEME, process.execPath, [resolve(process.argv[1])]);
} else {
app.removeAsDefaultProtocolClient(SCHEME);
}
return status();
}
rmSync(DESKTOP_FILE, { force: true });
-1
View File
@@ -56,7 +56,6 @@ export async function joinInstance(url: string): Promise<JoinResult> {
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 };
}
+40 -8
View File
@@ -1,10 +1,11 @@
import { app, BrowserWindow } from "electron";
import { join } from "node:path";
import { registerIpcHandlers } from "./ipc/handlers";
import { reconcile as reconcileEnhancements } from "./enhancements/service";
import { reconcile as reconcileEnhancements, isEnabled } from "./enhancements/service";
import { registerGalleryScheme, registerGalleryProtocol } from "./gallery/protocol";
import { startSocialBridge } from "./store/social";
import { startWatcher as startGameWatcher, joinInstance } from "./game/launch";
import { parseLocation } from "../shared/types/user";
import { startRegionDetection } from "./game/region";
import { startGalleryWatch, stopGalleryWatch } from "./gallery/watcher";
import { startDebugBridge } from "./debug/bridge";
@@ -13,12 +14,43 @@ import { userCache } from "./vrchat/userService";
import { repos } from "./store/repository/manager";
import { activeId } from "./accounts/store";
import { closeClients } from "./vrchat/client";
import { createMainWindow, focusMainWindow } from "./windows";
import { createMainWindow, focusMainWindow, broadcast } from "./windows";
function locationFromVrchatUrl(url: string): string | undefined {
try {
return new URL(url).searchParams.get("id") ?? undefined;
} catch {
return undefined;
}
}
function handleVrchatUrl(url: string | undefined): boolean {
if (!url || !url.startsWith("vrchat://")) return false;
if (!isEnabled("vrchat-protocol-handler")) {
logger.info("game", "ignoring vrchat:// url; handler disabled", { url });
return false;
}
function handleVrchatUrl(url: string | undefined): void {
if (!url || !url.startsWith("vrchat://")) return;
logger.info("game", "received vrchat:// url", { url });
if (process.platform === "darwin") {
const location = locationFromVrchatUrl(url);
const parsed = parseLocation(location);
if (!parsed) {
logger.warn("game", "could not parse instance location from vrchat:// url", { url });
return false;
}
broadcast("instance:open", {
worldId: parsed.worldId,
instanceId: parsed.instance,
location: location!,
});
return true;
}
joinInstance(url).catch((err) => logger.warn("game", "join from protocol url failed", err));
return true;
}
function vrchatUrlFromArgv(argv: string[]): string | undefined {
@@ -30,13 +62,13 @@ if (!app.requestSingleInstanceLock()) {
} else {
// already-running case (win32/linux): the new instance's argv carries the url
app.on("second-instance", (_e, argv) => {
focusMainWindow();
handleVrchatUrl(vrchatUrlFromArgv(argv));
const url = vrchatUrlFromArgv(argv);
// a disabled protocol link must not steal focus; a plain re-launch still should
if (url ? handleVrchatUrl(url) : true) focusMainWindow();
});
// macOS delivers protocol urls here, both cold and warm
app.on("open-url", (_e, url) => {
focusMainWindow();
handleVrchatUrl(url);
if (handleVrchatUrl(url)) focusMainWindow();
});
registerGalleryScheme();
start();
+7 -7
View File
@@ -127,10 +127,10 @@ function subscribe(vrc: VRChat & Pipeline): void {
if (p) entityStore.upsert(state ? { ...p, state } : p);
};
vrc.on("friend-update", (d) => patch(d, undefined, true));
vrc.on("user-update", (d) => patch(d, undefined, true));
vrc.on("friend-online", (d) => patch(d, "online"));
vrc.on("friend-active", (d) => patch(d, "active"));
vrc.on("friend-update", (d: unknown) => patch(d, undefined, true));
vrc.on("user-update", (d: unknown) => patch(d, undefined, true));
vrc.on("friend-online", (d: unknown) => patch(d, "online"));
vrc.on("friend-active", (d: unknown) => patch(d, "active"));
const location = (data: unknown) => {
if (!active()) return;
@@ -143,13 +143,13 @@ function subscribe(vrc: VRChat & Pipeline): void {
vrc.on("friend-location", location);
vrc.on("user-location", location);
vrc.on("friend-offline", (data) => {
vrc.on("friend-offline", (data: unknown) => {
if (!active()) return;
const id = (asRecord(data).userId ?? userOf(data).id) as string | undefined;
if (id) entityStore.upsert({ id, state: "offline", location: "offline" });
});
vrc.on("friend-add", async (data) => {
vrc.on("friend-add", async (data: unknown) => {
if (!active()) return;
const id = (asRecord(data).userId ?? userOf(data).id) as string | undefined;
if (!id) return;
@@ -158,7 +158,7 @@ function subscribe(vrc: VRChat & Pipeline): void {
} catch {}
});
vrc.on("friend-delete", (data) => {
vrc.on("friend-delete", (data: unknown) => {
if (!active()) return;
const id = (asRecord(data).userId ?? userOf(data).id) as string | undefined;
if (id) entityStore.removeFriend(id);
+10 -2
View File
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import {
ArrowLeft,
ChevronLeft,
@@ -27,7 +27,7 @@ import { AccountSwitcher } from "../features/auth/AccountSwitcher";
import { LaunchButton } from "../features/game/LaunchButton";
import { NavProvider, useNav, type View } from "../features/navigation/NavContext";
import { useI18n } from "../lib/i18n";
import { api } from "../lib/api";
import { api, events } from "../lib/api";
import "../styles/app-shell.css";
export function AppShell() {
@@ -53,6 +53,14 @@ function Shell() {
const [leftOpen, setLeftOpen] = useState(true);
const [friendsOpen, setFriendsOpen] = useState(true);
useEffect(
() =>
events.on("instance:open", ({ worldId, instanceId, location }) =>
nav.openInstance(worldId, instanceId, location),
),
[nav],
);
const navItems: NavItem[] = [
{
id: "search",
@@ -27,7 +27,7 @@ const CATALOG: Meta[] = [
{
id: "vrchat-protocol-handler",
icon: <Link2 size={18} />,
platforms: ["linux", "win32"], // TODO: add darwin with the in-app instance page
platforms: ["linux", "win32", "darwin"],
},
];
@@ -212,17 +212,21 @@ function ResultView({ instance }: { instance: Instance }) {
}
};
const canLaunch = window.api.platform !== "darwin";
return (
<div className="flex flex-col gap-4">
<p className="text-text">{t("world:create.ready")}</p>
<div className="flex flex-col gap-1.5">
<Button variant="primary" onClick={launch} loading={launching} block>
{!launching ? <Play size={15} /> : null}
{launching ? t("world:create.launching") : t("world:create.launch")}
</Button>
{launchError ? <span className="text-[12px] text-danger">{launchError}</span> : null}
</div>
{canLaunch ? (
<div className="flex flex-col gap-1.5">
<Button variant="primary" onClick={launch} loading={launching} block>
{!launching ? <Play size={15} /> : null}
{launching ? t("world:create.launching") : t("world:create.launch")}
</Button>
{launchError ? <span className="text-[12px] text-danger">{launchError}</span> : null}
</div>
) : null}
<div className="flex flex-col gap-1.5">
<Button variant="ghost" onClick={selfInvite} disabled={inviteSent} block>
@@ -158,6 +158,8 @@ function JoinActions({ instance }: { instance: Instance }) {
}
};
const canLaunch = window.api.platform !== "darwin";
return (
<div className="mb-1 ml-auto flex shrink-0 flex-col items-end gap-1.5">
<div className="flex gap-1.5">
@@ -165,10 +167,12 @@ function JoinActions({ instance }: { instance: Instance }) {
{inviteSent ? <Check size={15} /> : <Send size={15} />}
{inviteSent ? t("world:instance.inviteSent") : t("world:instance.inviteMe")}
</Button>
<Button variant="primary" onClick={join} loading={joining}>
{!joining ? <Play size={15} /> : null}
{joining ? t("world:instance.joining") : t("world:instance.join")}
</Button>
{canLaunch ? (
<Button variant="primary" onClick={join} loading={joining}>
{!joining ? <Play size={15} /> : null}
{joining ? t("world:instance.joining") : t("world:instance.join")}
</Button>
) : null}
</div>
{joinError ? <span className="text-[12px] text-danger">{joinError}</span> : null}
{inviteError ? <span className="text-[12px] text-danger">{inviteError}</span> : null}
+1
View File
@@ -143,6 +143,7 @@ export interface IpcEvents {
"group:upsert": Group;
"world:favoriteFolders": { userId: string; folders: FavoriteWorldFolder[]; done: boolean };
"game:changed": GameStatus;
"instance:open": { worldId: string; instanceId: string; location: string };
"gallery:added": Photo;
"debug:log": LogEntry;
"debug:cache": CacheUpdate;