diff --git a/electron-builder.yml b/electron-builder.yml index d2be98d..09ba9ab 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -14,3 +14,6 @@ win: mac: target: [dmg] category: public.app-category.social-networking + protocols: + - name: VRChat + schemes: [vrchat] diff --git a/package.json b/package.json index a51de8c..5bc26aa 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/main/enhancements/service.ts b/src/main/enhancements/service.ts index bbb0229..d6e772f 100644 --- a/src/main/enhancements/service.ts +++ b/src/main/enhancements/service.ts @@ -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, diff --git a/src/main/enhancements/vrchatProtocol.ts b/src/main/enhancements/vrchatProtocol.ts index 9837c4e..8c78bfd 100644 --- a/src/main/enhancements/vrchatProtocol.ts +++ b/src/main/enhancements/vrchatProtocol.ts @@ -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 { + 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 { } export async function disable(): Promise { + 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 }); diff --git a/src/main/game/launch.ts b/src/main/game/launch.ts index 297b4a4..dbf3db1 100644 --- a/src/main/game/launch.ts +++ b/src/main/game/launch.ts @@ -56,7 +56,6 @@ export async function joinInstance(url: string): Promise { 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 }; } diff --git a/src/main/index.ts b/src/main/index.ts index 9f6be4a..3dffcb8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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(); diff --git a/src/main/store/social.ts b/src/main/store/social.ts index 7618162..fa90498 100644 --- a/src/main/store/social.ts +++ b/src/main/store/social.ts @@ -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); diff --git a/src/renderer/src/components/AppShell.tsx b/src/renderer/src/components/AppShell.tsx index 9b8ec2e..941084c 100644 --- a/src/renderer/src/components/AppShell.tsx +++ b/src/renderer/src/components/AppShell.tsx @@ -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", diff --git a/src/renderer/src/features/enhancements/EnhancementsView.tsx b/src/renderer/src/features/enhancements/EnhancementsView.tsx index 8f34634..e61c114 100644 --- a/src/renderer/src/features/enhancements/EnhancementsView.tsx +++ b/src/renderer/src/features/enhancements/EnhancementsView.tsx @@ -27,7 +27,7 @@ const CATALOG: Meta[] = [ { id: "vrchat-protocol-handler", icon: , - platforms: ["linux", "win32"], // TODO: add darwin with the in-app instance page + platforms: ["linux", "win32", "darwin"], }, ]; diff --git a/src/renderer/src/features/world/CreateInstanceModal.tsx b/src/renderer/src/features/world/CreateInstanceModal.tsx index bdd9d9a..242874e 100644 --- a/src/renderer/src/features/world/CreateInstanceModal.tsx +++ b/src/renderer/src/features/world/CreateInstanceModal.tsx @@ -212,17 +212,21 @@ function ResultView({ instance }: { instance: Instance }) { } }; + const canLaunch = window.api.platform !== "darwin"; + return (

{t("world:create.ready")}

-
- - {launchError ? {launchError} : null} -
+ {canLaunch ? ( +
+ + {launchError ? {launchError} : null} +
+ ) : null}
- + {canLaunch ? ( + + ) : null}
{joinError ? {joinError} : null} {inviteError ? {inviteError} : null} diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index ff05f45..179cfc3 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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;