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