mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-07-15 14:27:48 +00:00
✨ feat: Implement VRChat protocol handling for macOS
This commit is contained in:
@@ -14,3 +14,6 @@ win:
|
||||
mac:
|
||||
target: [dmg]
|
||||
category: public.app-category.social-networking
|
||||
protocols:
|
||||
- name: VRChat
|
||||
schemes: [vrchat]
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user