feat: Implement VRChat protocol handling for macOS

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