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