mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 19:08:52 +00:00
✨ feat: Create Instance, Auto Instance Region
This commit is contained in:
@@ -2,13 +2,13 @@ import { app } from "electron";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { writeFileAtomicSync } from "../lib/atomicFile";
|
import { writeFileAtomicSync } from "../lib/atomicFile";
|
||||||
import type { AppConfig } from "../../shared/types/appConfig";
|
import type { AppConfig, PreferredRegion } from "../../shared/types/appConfig";
|
||||||
import { detectedGamePath } from "../game/steam";
|
import { detectedGamePath } from "../game/steam";
|
||||||
|
|
||||||
const path = () => join(app.getPath("userData"), "app-config.json");
|
const path = () => join(app.getPath("userData"), "app-config.json");
|
||||||
|
|
||||||
type StoredConfig = Omit<AppConfig, "version" | "detectedGamePath">;
|
type StoredConfig = Omit<AppConfig, "version" | "detectedGamePath">;
|
||||||
const DEFAULTS: StoredConfig = { gamePath: null };
|
const DEFAULTS: StoredConfig = { gamePath: null, preferredRegion: "auto" };
|
||||||
|
|
||||||
function readStored(): StoredConfig {
|
function readStored(): StoredConfig {
|
||||||
try {
|
try {
|
||||||
@@ -26,6 +26,16 @@ export function gamePathOverride(): string | null {
|
|||||||
return readStored().gamePath;
|
return readStored().gamePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function preferredRegion(): PreferredRegion {
|
||||||
|
return readStored().preferredRegion;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPreferredRegion(region: PreferredRegion): AppConfig {
|
||||||
|
const next: StoredConfig = { ...readStored(), preferredRegion: region };
|
||||||
|
writeFileAtomicSync(path(), JSON.stringify(next, null, 2));
|
||||||
|
return withDerived(next);
|
||||||
|
}
|
||||||
|
|
||||||
export function getConfig(): AppConfig {
|
export function getConfig(): AppConfig {
|
||||||
return withDerived(readStored());
|
return withDerived(readStored());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { connect } from "node:net";
|
||||||
|
import type { InstanceRegion } from "../../shared/types/instance";
|
||||||
|
import type { RegionPing } from "../../shared/types/appConfig";
|
||||||
|
import { logger } from "../debug/logger";
|
||||||
|
import { preferredRegion } from "../config/appConfig";
|
||||||
|
|
||||||
|
// VRChat's photon game servers don't expose stable public ping hostnames, so we
|
||||||
|
// measure latency to a public host physically co-located with each region's
|
||||||
|
// datacenter
|
||||||
|
const PING_HOSTS: Record<InstanceRegion, string> = {
|
||||||
|
us: "ec2.us-west-1.amazonaws.com",
|
||||||
|
use: "ec2.us-east-1.amazonaws.com",
|
||||||
|
eu: "ec2.eu-central-1.amazonaws.com",
|
||||||
|
jp: "ec2.ap-northeast-1.amazonaws.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
const REGIONS = Object.keys(PING_HOSTS) as InstanceRegion[];
|
||||||
|
const PORT = 443;
|
||||||
|
const ATTEMPTS = 3;
|
||||||
|
const TIMEOUT_MS = 2000;
|
||||||
|
|
||||||
|
function tcpPing(host: string): Promise<number | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const start = performance.now();
|
||||||
|
const sock = connect({ host, port: PORT });
|
||||||
|
const done = (ms: number | null) => {
|
||||||
|
sock.destroy();
|
||||||
|
resolve(ms);
|
||||||
|
};
|
||||||
|
sock.setTimeout(TIMEOUT_MS);
|
||||||
|
sock.once("connect", () => done(performance.now() - start));
|
||||||
|
sock.once("timeout", () => done(null));
|
||||||
|
sock.once("error", () => done(null));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measure(host: string): Promise<number | null> {
|
||||||
|
const samples: number[] = [];
|
||||||
|
for (let i = 0; i < ATTEMPTS; i++) {
|
||||||
|
const ms = await tcpPing(host);
|
||||||
|
if (ms !== null) samples.push(ms);
|
||||||
|
}
|
||||||
|
if (samples.length === 0) return null;
|
||||||
|
samples.sort((a, b) => a - b);
|
||||||
|
const kept = samples.length > 1 ? samples.slice(0, -1) : samples;
|
||||||
|
return Math.round(kept.reduce((a, b) => a + b, 0) / kept.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
function regionFromTimezone(): InstanceRegion {
|
||||||
|
let tz = "";
|
||||||
|
try {
|
||||||
|
tz = Intl.DateTimeFormat().resolvedOptions().timeZone ?? "";
|
||||||
|
} catch {}
|
||||||
|
if (/^Asia\//.test(tz)) return "jp";
|
||||||
|
if (/^(Europe|Africa)\//.test(tz)) return "eu";
|
||||||
|
if (
|
||||||
|
/^America\/(New_York|Detroit|Toronto|Montreal|Halifax|Indiana|Kentucky|Chicago|Sao_Paulo)/.test(
|
||||||
|
tz,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "use";
|
||||||
|
}
|
||||||
|
return "us";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pingRegions(): Promise<RegionPing[]> {
|
||||||
|
return Promise.all(
|
||||||
|
REGIONS.map(async (region) => ({ region, ms: await measure(PING_HOSTS[region]) })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DETECT_TTL_MS = 30 * 60 * 1000;
|
||||||
|
let cached: { region: InstanceRegion; at: number } | null = null;
|
||||||
|
|
||||||
|
export function invalidateRegionCache(): void {
|
||||||
|
cached = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectBestRegion(): Promise<InstanceRegion> {
|
||||||
|
if (cached && Date.now() - cached.at < DETECT_TTL_MS) return cached.region;
|
||||||
|
|
||||||
|
const pings = await pingRegions();
|
||||||
|
const reachable = pings.filter((p): p is RegionPing & { ms: number } => p.ms !== null);
|
||||||
|
if (reachable.length === 0) {
|
||||||
|
const fallback = regionFromTimezone();
|
||||||
|
logger.info("region", "all pings failed, using timezone fallback", { fallback });
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const best = reachable.reduce((a, b) => (b.ms < a.ms ? b : a));
|
||||||
|
cached = { region: best.region, at: Date.now() };
|
||||||
|
logger.info("region", "detected best region", { region: best.region, ms: best.ms });
|
||||||
|
return best.region;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REFRESH_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
export function startRegionDetection(): void {
|
||||||
|
const tick = (): void => {
|
||||||
|
if (preferredRegion() !== "auto") return;
|
||||||
|
invalidateRegionCache();
|
||||||
|
void detectBestRegion();
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
setInterval(tick, REFRESH_MS).unref();
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { reconcile as reconcileEnhancements } 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 { 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";
|
||||||
import { logger } from "./debug/logger";
|
import { logger } from "./debug/logger";
|
||||||
@@ -73,6 +74,7 @@ function start(): void {
|
|||||||
startDebugBridge();
|
startDebugBridge();
|
||||||
startGameWatcher();
|
startGameWatcher();
|
||||||
startGalleryWatch();
|
startGalleryWatch();
|
||||||
|
startRegionDetection();
|
||||||
createMainWindow();
|
createMainWindow();
|
||||||
|
|
||||||
handleVrchatUrl(vrchatUrlFromArgv(process.argv));
|
handleVrchatUrl(vrchatUrlFromArgv(process.argv));
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import * as gallery from "../gallery/service";
|
|||||||
import { thumbStats, clearThumbnails } from "../gallery/thumbnails";
|
import { thumbStats, clearThumbnails } from "../gallery/thumbnails";
|
||||||
import * as appConfig from "../config/appConfig";
|
import * as appConfig from "../config/appConfig";
|
||||||
import * as game from "../game/launch";
|
import * as game from "../game/launch";
|
||||||
|
import * as region from "../game/region";
|
||||||
import { socialSnapshot } from "../store/social";
|
import { socialSnapshot } from "../store/social";
|
||||||
import { worldStore } from "../store/worldStore";
|
import { worldStore } from "../store/worldStore";
|
||||||
import { groupStore } from "../store/groupStore";
|
import { groupStore } from "../store/groupStore";
|
||||||
@@ -54,6 +55,9 @@ const handlers = {
|
|||||||
|
|
||||||
"instance:get": ({ worldId, instanceId }) =>
|
"instance:get": ({ worldId, instanceId }) =>
|
||||||
guard(() => instances.getInstance(worldId, instanceId)),
|
guard(() => instances.getInstance(worldId, instanceId)),
|
||||||
|
"instance:create": (input) => guard(() => instances.createInstance(input)),
|
||||||
|
"instance:inviteSelf": ({ worldId, instanceId }) =>
|
||||||
|
guard(() => instances.inviteSelf(worldId, instanceId)),
|
||||||
|
|
||||||
"avatar:get": (avatarId) => guard(() => avatars.getAvatar(avatarId)),
|
"avatar:get": (avatarId) => guard(() => avatars.getAvatar(avatarId)),
|
||||||
"avatar:favorites": () => guard(() => avatars.getFavoritedAvatars()),
|
"avatar:favorites": () => guard(() => avatars.getFavoritedAvatars()),
|
||||||
@@ -94,6 +98,19 @@ const handlers = {
|
|||||||
if (res.canceled || !res.filePaths[0]) return appConfig.getConfig();
|
if (res.canceled || !res.filePaths[0]) return appConfig.getConfig();
|
||||||
return appConfig.setGamePath(res.filePaths[0]);
|
return appConfig.setGamePath(res.filePaths[0]);
|
||||||
}),
|
}),
|
||||||
|
"config:setPreferredRegion": (p) =>
|
||||||
|
guard(async () => {
|
||||||
|
const next = appConfig.setPreferredRegion(p.region);
|
||||||
|
if (p.region === "auto") void region.detectBestRegion();
|
||||||
|
return next;
|
||||||
|
}),
|
||||||
|
|
||||||
|
"region:detect": () => guard(() => region.detectBestRegion()),
|
||||||
|
"region:ping": () =>
|
||||||
|
guard(() => {
|
||||||
|
region.invalidateRegionCache();
|
||||||
|
return region.pingRegions();
|
||||||
|
}),
|
||||||
|
|
||||||
"unity:status": () => guard(() => unity.unityStatus()),
|
"unity:status": () => guard(() => unity.unityStatus()),
|
||||||
"unity:install": (url) =>
|
"unity:install": (url) =>
|
||||||
@@ -105,6 +122,10 @@ const handlers = {
|
|||||||
|
|
||||||
"game:status": () => guard(() => game.status()),
|
"game:status": () => guard(() => game.status()),
|
||||||
"game:launch": () => guard(() => game.launch()),
|
"game:launch": () => guard(() => game.launch()),
|
||||||
|
"game:join": ({ location }) =>
|
||||||
|
guard(async () => {
|
||||||
|
await game.joinInstance(`vrchat://launch?ref=vrchat.com&id=${location}`);
|
||||||
|
}),
|
||||||
|
|
||||||
"gallery:snapshot": () => guard(async () => gallery.snapshot()),
|
"gallery:snapshot": () => guard(async () => gallery.snapshot()),
|
||||||
"gallery:reveal": (path) => guard(async () => void shell.showItemInFolder(path)),
|
"gallery:reveal": (path) => guard(async () => void shell.showItemInFolder(path)),
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import type { Instance } from "../../shared/types/instance";
|
import type {
|
||||||
|
Instance,
|
||||||
|
CreateInstanceInput,
|
||||||
|
CreateInstanceType,
|
||||||
|
} from "../../shared/types/instance";
|
||||||
import { toInstance } from "./mappers";
|
import { toInstance } from "./mappers";
|
||||||
import { cachedRead } from "./cachedRead";
|
import { cachedRead } from "./cachedRead";
|
||||||
|
import { requireActiveClient } from "./client";
|
||||||
|
import { currentUser } from "./userService";
|
||||||
import { cacheKeys, policies } from "../cache/policies";
|
import { cacheKeys, policies } from "../cache/policies";
|
||||||
|
|
||||||
export async function getInstance(worldId: string, instanceId: string): Promise<Instance> {
|
export async function getInstance(worldId: string, instanceId: string): Promise<Instance> {
|
||||||
@@ -13,3 +19,42 @@ export async function getInstance(worldId: string, instanceId: string): Promise<
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SdkType = "public" | "friends" | "hidden" | "private";
|
||||||
|
|
||||||
|
function mapCreateType(type: CreateInstanceType): { type: SdkType; canRequestInvite?: boolean } {
|
||||||
|
switch (type) {
|
||||||
|
case "public":
|
||||||
|
return { type: "public" };
|
||||||
|
case "friends+":
|
||||||
|
return { type: "hidden" };
|
||||||
|
case "friends":
|
||||||
|
return { type: "friends" };
|
||||||
|
case "invite":
|
||||||
|
return { type: "private" };
|
||||||
|
case "invite+":
|
||||||
|
return { type: "private", canRequestInvite: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInstance(input: CreateInstanceInput): Promise<Instance> {
|
||||||
|
const vrc = requireActiveClient();
|
||||||
|
const sdk = mapCreateType(input.type);
|
||||||
|
const ownerId = sdk.type === "public" ? undefined : (await currentUser()).id;
|
||||||
|
const { data } = await vrc.createInstance({
|
||||||
|
body: {
|
||||||
|
worldId: input.worldId,
|
||||||
|
region: input.region,
|
||||||
|
type: sdk.type,
|
||||||
|
...(ownerId ? { ownerId } : {}),
|
||||||
|
...(sdk.canRequestInvite ? { canRequestInvite: true } : {}),
|
||||||
|
},
|
||||||
|
throwOnError: true,
|
||||||
|
});
|
||||||
|
return toInstance(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inviteSelf(worldId: string, instanceId: string): Promise<void> {
|
||||||
|
const vrc = requireActiveClient();
|
||||||
|
await vrc.inviteMyselfTo({ path: { worldId, instanceId }, throwOnError: true });
|
||||||
|
}
|
||||||
|
|||||||
@@ -211,6 +211,8 @@ export function toInstance(raw: SdkInstance): Instance {
|
|||||||
full: raw.full ?? false,
|
full: raw.full ?? false,
|
||||||
queueEnabled: raw.queueEnabled ?? false,
|
queueEnabled: raw.queueEnabled ?? false,
|
||||||
queueSize: raw.queueSize ?? 0,
|
queueSize: raw.queueSize ?? 0,
|
||||||
|
secureName: raw.secureName ?? undefined,
|
||||||
|
shortName: raw.shortName ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,45 @@
|
|||||||
import type { ImgHTMLAttributes } from "react";
|
import { useEffect, useRef, useState, type ImgHTMLAttributes } from "react";
|
||||||
|
|
||||||
export function HoverImage({
|
export function HoverImage({
|
||||||
className = "",
|
className = "",
|
||||||
alt = "",
|
alt = "",
|
||||||
|
src,
|
||||||
...props
|
...props
|
||||||
}: ImgHTMLAttributes<HTMLImageElement>) {
|
}: ImgHTMLAttributes<HTMLImageElement>) {
|
||||||
|
const ref = useRef<HTMLSpanElement>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el || visible) return;
|
||||||
|
const io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
if (entries.some((e) => e.isIntersecting)) {
|
||||||
|
setVisible(true);
|
||||||
|
io.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "300px" },
|
||||||
|
);
|
||||||
|
io.observe(el);
|
||||||
|
return () => io.disconnect();
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<img
|
<span ref={ref} className="block size-full bg-surface-hover">
|
||||||
alt={alt}
|
{visible && src ? (
|
||||||
className={`size-full object-cover transition-transform duration-300 group-hover:scale-105 ${className}`}
|
<img
|
||||||
{...props}
|
alt={alt}
|
||||||
/>
|
src={src}
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
decoding="async"
|
||||||
|
className={`size-full object-cover transition-[transform,opacity] duration-300 group-hover:scale-105 ${
|
||||||
|
loaded ? "opacity-100" : "opacity-0"
|
||||||
|
} ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, type ReactNode } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
|
import { useT } from "../../lib/i18n";
|
||||||
|
|
||||||
type ModalProps = {
|
type ModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -25,11 +26,12 @@ export function Modal({
|
|||||||
children,
|
children,
|
||||||
danger,
|
danger,
|
||||||
confirmLabel,
|
confirmLabel,
|
||||||
cancelLabel = "Cancel",
|
cancelLabel,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
confirmLoading,
|
confirmLoading,
|
||||||
confirmDisabled,
|
confirmDisabled,
|
||||||
}: ModalProps) {
|
}: ModalProps) {
|
||||||
|
const t = useT();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -78,7 +80,7 @@ export function Modal({
|
|||||||
{onConfirm ? (
|
{onConfirm ? (
|
||||||
<div className="mt-5 flex justify-end gap-2.5">
|
<div className="mt-5 flex justify-end gap-2.5">
|
||||||
<Button variant="ghost" onClick={onClose} disabled={confirmLoading}>
|
<Button variant="ghost" onClick={onClose} disabled={confirmLoading}>
|
||||||
{cancelLabel}
|
{cancelLabel ?? t("common:cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={danger ? "danger" : "primary"}
|
variant={danger ? "danger" : "primary"}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { useWorld } from "../../store/worlds";
|
|||||||
import { useNav } from "../navigation/NavContext";
|
import { useNav } from "../navigation/NavContext";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { useAsync } from "../../lib/useAsync";
|
import { useAsync } from "../../lib/useAsync";
|
||||||
import { locationLabel, regionLabels } from "../../lib/vrchat";
|
import { locationLabel, regionLabels, regionFlag } from "../../lib/vrchat";
|
||||||
|
|
||||||
export function LocationSection({ location }: { location?: Location }) {
|
export function LocationSection({ location }: { location?: Location }) {
|
||||||
const { openWorld } = useNav();
|
const { openWorld } = useNav();
|
||||||
@@ -36,6 +36,7 @@ export function LocationSection({ location }: { location?: Location }) {
|
|||||||
const region = parsed.region
|
const region = parsed.region
|
||||||
? (regionLabels[parsed.region] ?? parsed.region.toUpperCase())
|
? (regionLabels[parsed.region] ?? parsed.region.toUpperCase())
|
||||||
: null;
|
: null;
|
||||||
|
const flag = regionFlag(parsed.region);
|
||||||
return (
|
return (
|
||||||
<Wrap>
|
<Wrap>
|
||||||
<button
|
<button
|
||||||
@@ -57,7 +58,12 @@ export function LocationSection({ location }: { location?: Location }) {
|
|||||||
</span>
|
</span>
|
||||||
{region ? (
|
{region ? (
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
<Globe size={11} /> {region}
|
{flag ? (
|
||||||
|
<span className="text-[13px] leading-none">{flag}</span>
|
||||||
|
) : (
|
||||||
|
<Globe size={11} />
|
||||||
|
)}{" "}
|
||||||
|
{region}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{inInstance ? (
|
{inInstance ? (
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
Droplet,
|
Droplet,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Gamepad2,
|
Gamepad2,
|
||||||
|
Gauge,
|
||||||
|
Globe2,
|
||||||
Info,
|
Info,
|
||||||
Languages,
|
Languages,
|
||||||
Monitor,
|
Monitor,
|
||||||
@@ -19,10 +21,11 @@ import {
|
|||||||
import { useTheme } from "../../lib/ThemeContext";
|
import { useTheme } from "../../lib/ThemeContext";
|
||||||
import { useI18n } from "../../lib/i18n";
|
import { useI18n } from "../../lib/i18n";
|
||||||
import { ACCENT_PRESETS, DEFAULT_ACCENT, type SchemeMode } from "../../lib/theme";
|
import { ACCENT_PRESETS, DEFAULT_ACCENT, type SchemeMode } from "../../lib/theme";
|
||||||
import type { AppConfig } from "../../../../shared/types/appConfig";
|
import type { AppConfig, PreferredRegion, RegionPing } from "../../../../shared/types/appConfig";
|
||||||
import type { UnityStatus } from "../../../../shared/types/unity";
|
import type { UnityStatus } from "../../../../shared/types/unity";
|
||||||
import { api, errorMessage } from "../../lib/api";
|
import { api, errorMessage } from "../../lib/api";
|
||||||
import { useAsync } from "../../lib/useAsync";
|
import { useAsync } from "../../lib/useAsync";
|
||||||
|
import { regionFlag, regionLabels } from "../../lib/vrchat";
|
||||||
import { Button, Field, Tabs } from "../../components/ui";
|
import { Button, Field, Tabs } from "../../components/ui";
|
||||||
|
|
||||||
const SHELL = "mx-auto flex w-full max-w-[760px] flex-col gap-[18px] px-12 pb-16 pt-10";
|
const SHELL = "mx-auto flex w-full max-w-[760px] flex-col gap-[18px] px-12 pb-16 pt-10";
|
||||||
@@ -62,6 +65,7 @@ export function SettingsView() {
|
|||||||
{tab === "game" ? (
|
{tab === "game" ? (
|
||||||
<div className={SECTIONS}>
|
<div className={SECTIONS}>
|
||||||
<GameSection />
|
<GameSection />
|
||||||
|
<RegionSection />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -380,6 +384,112 @@ function Notice({ error, ok }: { error?: string | null; ok?: string | null }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REGION_OPTIONS: PreferredRegion[] = ["auto", "us", "use", "eu", "jp"];
|
||||||
|
|
||||||
|
function RegionSection() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [region, setRegion] = useState<PreferredRegion>("auto");
|
||||||
|
const [ok, setOk] = useState<string | null>(null);
|
||||||
|
const [pings, setPings] = useState<RegionPing[] | null>(null);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.config.get().then((c) => setRegion(c.preferredRegion));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const choose = async (r: PreferredRegion) => {
|
||||||
|
setRegion(r);
|
||||||
|
setOk(null);
|
||||||
|
const next = await api.config.setPreferredRegion(r);
|
||||||
|
setRegion(next.preferredRegion);
|
||||||
|
setOk(t("settings:region.saved"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const test = async () => {
|
||||||
|
setTesting(true);
|
||||||
|
setPings(null);
|
||||||
|
try {
|
||||||
|
setPings(await api.region.ping());
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const best = pings
|
||||||
|
?.filter((p): p is RegionPing & { ms: number } => p.ms !== null)
|
||||||
|
.reduce<(RegionPing & { ms: number }) | null>((a, b) => (!a || b.ms < a.ms ? b : a), null);
|
||||||
|
|
||||||
|
const label = (r: PreferredRegion) =>
|
||||||
|
r === "auto"
|
||||||
|
? t("settings:region.auto")
|
||||||
|
: `${regionFlag(r) ?? ""} ${regionLabels[r] ?? r.toUpperCase()}`.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
title={t("settings:region.title")}
|
||||||
|
icon={<Globe2 size={16} />}
|
||||||
|
description={t("settings:region.description")}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{REGION_OPTIONS.map((r) => {
|
||||||
|
const active = r === region;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
type="button"
|
||||||
|
onClick={() => void choose(r)}
|
||||||
|
aria-pressed={active}
|
||||||
|
className={`rounded-lg border px-4 py-2.5 text-[13.5px] font-semibold transition-colors ${
|
||||||
|
active
|
||||||
|
? "border-accent bg-[color-mix(in_srgb,var(--accent)_8%,var(--surface-2))]"
|
||||||
|
: "border-border bg-surface-2 hover:border-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label(r)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-2.5">
|
||||||
|
<Button variant="ghost" onClick={() => void test()} loading={testing}>
|
||||||
|
<Gauge size={14} />
|
||||||
|
{testing ? t("settings:region.testing") : t("settings:region.test")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pings ? (
|
||||||
|
<div className="mt-3 flex flex-col gap-1.5">
|
||||||
|
{pings.map((p) => (
|
||||||
|
<div key={p.region} className="flex items-center gap-2 text-[13px]">
|
||||||
|
<span className="w-28 shrink-0 text-muted">
|
||||||
|
{regionFlag(p.region) ?? ""} {regionLabels[p.region] ?? p.region.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{p.ms === null ? (
|
||||||
|
<span className="text-faint">{t("settings:region.unreachable")}</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="font-mono font-semibold"
|
||||||
|
style={best?.region === p.region ? { color: "var(--status-active)" } : undefined}
|
||||||
|
>
|
||||||
|
{p.ms} ms
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{best?.region === p.region ? (
|
||||||
|
<span className="text-[11px] font-semibold text-[var(--status-active)]">
|
||||||
|
{t("settings:region.best")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Notice ok={ok} />
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function UnitySection() {
|
function UnitySection() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const status = useAsync(() => api.unity.status(), [], t("settings:unity.error"));
|
const status = useAsync(() => api.unity.status(), [], t("settings:unity.error"));
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Check, Copy, Globe, Play, Send, Sparkles } from "lucide-react";
|
||||||
|
import { Banner, Button, Modal } from "../../components/ui";
|
||||||
|
import { api, errorMessage } from "../../lib/api";
|
||||||
|
import { useCopied } from "../../lib/useCopied";
|
||||||
|
import { regionFlag } from "../../lib/vrchat";
|
||||||
|
import { useT } from "../../lib/i18n";
|
||||||
|
import type {
|
||||||
|
CreateInstanceInput,
|
||||||
|
CreateInstanceType,
|
||||||
|
Instance,
|
||||||
|
InstanceRegion,
|
||||||
|
} from "../../../../shared/types/instance";
|
||||||
|
|
||||||
|
const TYPES: CreateInstanceType[] = ["public", "friends+", "friends", "invite+", "invite"];
|
||||||
|
const REGIONS: InstanceRegion[] = ["us", "use", "eu", "jp"];
|
||||||
|
|
||||||
|
function launchLink(inst: Instance, name: string): string {
|
||||||
|
const instanceId = inst.location.slice(inst.location.indexOf(":") + 1);
|
||||||
|
const params = new URLSearchParams({ worldId: inst.worldId, instanceId, shortName: name });
|
||||||
|
return `https://vrchat.com/home/launch?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CreateInstanceModal({
|
||||||
|
worldId,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
worldId: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const [type, setType] = useState<CreateInstanceType>("invite");
|
||||||
|
const [region, setRegion] = useState<InstanceRegion | null>(null);
|
||||||
|
const [autoRegion, setAutoRegion] = useState(false);
|
||||||
|
const [detecting, setDetecting] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [instance, setInstance] = useState<Instance | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const touched = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
touched.current = false;
|
||||||
|
let cancelled = false;
|
||||||
|
void api.config.get().then(async (cfg) => {
|
||||||
|
if (cancelled || touched.current) return;
|
||||||
|
if (cfg.preferredRegion !== "auto") {
|
||||||
|
setAutoRegion(false);
|
||||||
|
setRegion(cfg.preferredRegion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAutoRegion(true);
|
||||||
|
setDetecting(true);
|
||||||
|
try {
|
||||||
|
const best = await api.region.detect();
|
||||||
|
if (!cancelled && !touched.current) setRegion(best);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setDetecting(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const pickRegion = (r: InstanceRegion) => {
|
||||||
|
touched.current = true;
|
||||||
|
setRegion(r);
|
||||||
|
};
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setInstance(null);
|
||||||
|
setError(null);
|
||||||
|
setCreating(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
onClose();
|
||||||
|
setTimeout(reset, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
if (!region) return;
|
||||||
|
setCreating(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const input: CreateInstanceInput = { worldId, type, region };
|
||||||
|
setInstance(await api.instance.create(input));
|
||||||
|
} catch (err) {
|
||||||
|
setError(errorMessage(err, "Failed to create instance"));
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
onClose={close}
|
||||||
|
title={t("world:create.title")}
|
||||||
|
icon={<Globe size={18} />}
|
||||||
|
confirmLabel={instance ? undefined : t("world:create.submit")}
|
||||||
|
onConfirm={instance ? undefined : create}
|
||||||
|
confirmLoading={creating}
|
||||||
|
confirmDisabled={!instance && !region}
|
||||||
|
>
|
||||||
|
{instance ? (
|
||||||
|
<ResultView instance={instance} />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{error ? <Banner>{error}</Banner> : null}
|
||||||
|
<Picker
|
||||||
|
label={t("world:create.type")}
|
||||||
|
value={type}
|
||||||
|
options={TYPES}
|
||||||
|
onChange={setType}
|
||||||
|
render={(v) => t(`world:create.types.${v}`)}
|
||||||
|
/>
|
||||||
|
<Picker
|
||||||
|
label={t("world:create.region")}
|
||||||
|
value={region}
|
||||||
|
options={REGIONS}
|
||||||
|
onChange={pickRegion}
|
||||||
|
render={(v) => `${regionFlag(v) ?? ""} ${t(`world:create.regions.${v}`)}`.trim()}
|
||||||
|
hint={
|
||||||
|
detecting ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-accent">
|
||||||
|
<Sparkles size={11} className="animate-pulse" />
|
||||||
|
{t("world:create.detecting")}
|
||||||
|
</span>
|
||||||
|
) : autoRegion && region ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-faint">
|
||||||
|
<Sparkles size={11} />
|
||||||
|
{t("world:create.autoDetected", {
|
||||||
|
region: t(`world:create.regions.${region}`),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Picker<T extends string>({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
render,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: T | null;
|
||||||
|
options: readonly T[];
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
render: (v: T) => string;
|
||||||
|
hint?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1.5 flex items-center gap-2 text-[12px] font-semibold uppercase tracking-wide text-faint">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{options.map((opt) => (
|
||||||
|
<Button
|
||||||
|
key={opt}
|
||||||
|
variant={opt === value ? "primary" : "ghost"}
|
||||||
|
onClick={() => onChange(opt)}
|
||||||
|
className="px-3.5 py-1.5 text-[13px]"
|
||||||
|
>
|
||||||
|
{render(opt)}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{hint ? <div className="mt-1.5 text-[12px]">{hint}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultView({ instance }: { instance: Instance }) {
|
||||||
|
const t = useT();
|
||||||
|
const [inviteSent, setInviteSent] = useState(false);
|
||||||
|
const [inviteError, setInviteError] = useState<string | null>(null);
|
||||||
|
const [launching, setLaunching] = useState(false);
|
||||||
|
const [launchError, setLaunchError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const launch = async () => {
|
||||||
|
setLaunching(true);
|
||||||
|
setLaunchError(null);
|
||||||
|
try {
|
||||||
|
await api.game.join(instance.location);
|
||||||
|
} catch (err) {
|
||||||
|
setLaunchError(errorMessage(err, "Failed to launch VRChat"));
|
||||||
|
} finally {
|
||||||
|
setLaunching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selfInvite = async () => {
|
||||||
|
setInviteError(null);
|
||||||
|
try {
|
||||||
|
await api.instance.inviteSelf(instance.worldId, instance.instanceId);
|
||||||
|
setInviteSent(true);
|
||||||
|
} catch (err) {
|
||||||
|
setInviteError(errorMessage(err, "Failed to send invite"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Button variant="ghost" onClick={selfInvite} disabled={inviteSent} block>
|
||||||
|
{inviteSent ? <Check size={15} /> : <Send size={15} />}
|
||||||
|
{inviteSent ? t("world:create.selfInviteSent") : t("world:create.selfInvite")}
|
||||||
|
</Button>
|
||||||
|
{inviteError ? <span className="text-[12px] text-danger">{inviteError}</span> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{instance.secureName ? (
|
||||||
|
<LinkRow
|
||||||
|
label={t("world:create.lockedLink")}
|
||||||
|
hint={t("world:create.lockedHint")}
|
||||||
|
link={launchLink(instance, instance.secureName)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{instance.shortName ? (
|
||||||
|
<LinkRow
|
||||||
|
label={t("world:create.unlockedLink")}
|
||||||
|
hint={t("world:create.unlockedHint")}
|
||||||
|
link={launchLink(instance, instance.shortName)}
|
||||||
|
warn
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkRow({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
link,
|
||||||
|
warn,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
link: string;
|
||||||
|
warn?: boolean;
|
||||||
|
}) {
|
||||||
|
const t = useT();
|
||||||
|
const [copied, copy] = useCopied();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Button variant="ghost" onClick={() => copy(link)} block>
|
||||||
|
{copied ? <Check size={15} /> : <Copy size={15} />}
|
||||||
|
{copied ? t("world:create.copied") : `${t("world:create.copy")} ${label}`}
|
||||||
|
</Button>
|
||||||
|
<span
|
||||||
|
className={`text-[12px] ${warn ? "" : "text-faint"}`}
|
||||||
|
style={warn ? { color: "var(--status-ask)" } : undefined}
|
||||||
|
>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import { Globe, Heart, Tag as TagIcon, Users } from "lucide-react";
|
import { useState } from "react";
|
||||||
|
import { Globe, Heart, Plus, Tag as TagIcon, Users } from "lucide-react";
|
||||||
import type { World } from "../../../../shared/types/world";
|
import type { World } from "../../../../shared/types/world";
|
||||||
import { Banner, Fact, Section, Skeleton, StatTile, Tag } from "../../components/ui";
|
import { Banner, Button, Fact, Section, Skeleton, StatTile, Tag } from "../../components/ui";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { useAsync } from "../../lib/useAsync";
|
import { useAsync } from "../../lib/useAsync";
|
||||||
import { compactNumber, formatDate, prettyTag } from "../../lib/format";
|
import { compactNumber, formatDate, prettyTag } from "../../lib/format";
|
||||||
import { useWorlds } from "../../store/worlds";
|
import { useWorlds } from "../../store/worlds";
|
||||||
import { useNav } from "../navigation/NavContext";
|
import { useNav } from "../navigation/NavContext";
|
||||||
|
import { useT } from "../../lib/i18n";
|
||||||
import { COL_WIDE } from "../../lib/layout";
|
import { COL_WIDE } from "../../lib/layout";
|
||||||
|
import { CreateInstanceModal } from "./CreateInstanceModal";
|
||||||
import "../profile/profile.css";
|
import "../profile/profile.css";
|
||||||
|
|
||||||
export function WorldView({ worldId }: { worldId: string }) {
|
export function WorldView({ worldId }: { worldId: string }) {
|
||||||
@@ -22,6 +25,8 @@ export function WorldView({ worldId }: { worldId: string }) {
|
|||||||
|
|
||||||
function WorldCard({ world }: { world: World }) {
|
function WorldCard({ world }: { world: World }) {
|
||||||
const { openUser } = useNav();
|
const { openUser } = useNav();
|
||||||
|
const t = useT();
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const banner = world.imageUrl || world.thumbnailImageUrl;
|
const banner = world.imageUrl || world.thumbnailImageUrl;
|
||||||
const players = world.occupants;
|
const players = world.occupants;
|
||||||
|
|
||||||
@@ -57,8 +62,18 @@ function WorldCard({ world }: { world: World }) {
|
|||||||
{world.platforms?.android ? <Tag color="var(--status-join)">Quest</Tag> : null}
|
{world.platforms?.android ? <Tag color="var(--status-join)">Quest</Tag> : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button className="mb-1 ml-auto shrink-0" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus size={15} />
|
||||||
|
{t("world:createInstance")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CreateInstanceModal
|
||||||
|
worldId={world.id}
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className={`${COL_WIDE} mt-6 flex flex-col gap-5`}>
|
<div className={`${COL_WIDE} mt-6 flex flex-col gap-5`}>
|
||||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<StatTile
|
<StatTile
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { ApiError } from "../../../shared/types/result";
|
|||||||
import type { ContentFilterKey } from "../../../shared/types/settings";
|
import type { ContentFilterKey } from "../../../shared/types/settings";
|
||||||
import type { UserStatus } from "../../../shared/types/user";
|
import type { UserStatus } from "../../../shared/types/user";
|
||||||
import type { EnhancementId } from "../../../shared/types/enhancements";
|
import type { EnhancementId } from "../../../shared/types/enhancements";
|
||||||
|
import type { CreateInstanceInput } from "../../../shared/types/instance";
|
||||||
|
import type { PreferredRegion } from "../../../shared/types/appConfig";
|
||||||
|
|
||||||
export class ApiException extends Error {
|
export class ApiException extends Error {
|
||||||
constructor(public readonly error: ApiError) {
|
constructor(public readonly error: ApiError) {
|
||||||
@@ -62,6 +64,9 @@ export const api = {
|
|||||||
},
|
},
|
||||||
instance: {
|
instance: {
|
||||||
get: (worldId: string, instanceId: string) => call("instance:get", { worldId, instanceId }),
|
get: (worldId: string, instanceId: string) => call("instance:get", { worldId, instanceId }),
|
||||||
|
create: (input: CreateInstanceInput) => call("instance:create", input),
|
||||||
|
inviteSelf: (worldId: string, instanceId: string) =>
|
||||||
|
call("instance:inviteSelf", { worldId, instanceId }),
|
||||||
},
|
},
|
||||||
avatar: {
|
avatar: {
|
||||||
get: (avatarId: string) => call("avatar:get", avatarId),
|
get: (avatarId: string) => call("avatar:get", avatarId),
|
||||||
@@ -104,6 +109,11 @@ export const api = {
|
|||||||
get: () => call("config:get"),
|
get: () => call("config:get"),
|
||||||
setGamePath: (gamePath: string | null) => call("config:setGamePath", { gamePath }),
|
setGamePath: (gamePath: string | null) => call("config:setGamePath", { gamePath }),
|
||||||
pickGamePath: () => call("config:pickGamePath"),
|
pickGamePath: () => call("config:pickGamePath"),
|
||||||
|
setPreferredRegion: (region: PreferredRegion) => call("config:setPreferredRegion", { region }),
|
||||||
|
},
|
||||||
|
region: {
|
||||||
|
detect: () => call("region:detect"),
|
||||||
|
ping: () => call("region:ping"),
|
||||||
},
|
},
|
||||||
unity: {
|
unity: {
|
||||||
status: () => call("unity:status"),
|
status: () => call("unity:status"),
|
||||||
@@ -112,6 +122,7 @@ export const api = {
|
|||||||
game: {
|
game: {
|
||||||
status: () => call("game:status"),
|
status: () => call("game:status"),
|
||||||
launch: () => call("game:launch"),
|
launch: () => call("game:launch"),
|
||||||
|
join: (location: string) => call("game:join", { location }),
|
||||||
},
|
},
|
||||||
gallery: {
|
gallery: {
|
||||||
snapshot: () => call("gallery:snapshot"),
|
snapshot: () => call("gallery:snapshot"),
|
||||||
|
|||||||
@@ -82,5 +82,16 @@
|
|||||||
"appName": "VRC Circle",
|
"appName": "VRC Circle",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"disclaimer": "Unofficial. Not affiliated with or endorsed by VRChat Inc."
|
"disclaimer": "Unofficial. Not affiliated with or endorsed by VRChat Inc."
|
||||||
|
},
|
||||||
|
"region": {
|
||||||
|
"title": "Instance Region",
|
||||||
|
"description": "The region new instances you create are hosted in. Auto picks the lowest-latency region for your connection.",
|
||||||
|
"auto": "Auto",
|
||||||
|
"autoHint": "Detect the best region",
|
||||||
|
"saved": "Region saved.",
|
||||||
|
"test": "Test latency",
|
||||||
|
"testing": "Pinging regions…",
|
||||||
|
"unreachable": "unreachable",
|
||||||
|
"best": "Best"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"createInstance": "Create Instance",
|
||||||
|
"create": {
|
||||||
|
"title": "Create Instance",
|
||||||
|
"type": "Access",
|
||||||
|
"region": "Region",
|
||||||
|
"submit": "Create",
|
||||||
|
"creating": "Creating…",
|
||||||
|
"detecting": "Detecting best region…",
|
||||||
|
"autoDetected": "Auto: {{region}}",
|
||||||
|
"types": {
|
||||||
|
"public": "Public",
|
||||||
|
"friends+": "Friends+",
|
||||||
|
"friends": "Friends",
|
||||||
|
"invite": "Invite",
|
||||||
|
"invite+": "Invite+"
|
||||||
|
},
|
||||||
|
"regions": {
|
||||||
|
"us": "US West",
|
||||||
|
"use": "US East",
|
||||||
|
"eu": "Europe",
|
||||||
|
"jp": "Japan"
|
||||||
|
},
|
||||||
|
"ready": "Instance created.",
|
||||||
|
"launch": "Launch VRChat",
|
||||||
|
"launching": "Launching…",
|
||||||
|
"selfInvite": "Invite myself",
|
||||||
|
"selfInviteSent": "Invite sent. Check VRChat.",
|
||||||
|
"lockedLink": "locked link",
|
||||||
|
"lockedHint": "Follows the instance rules. Safe to share.",
|
||||||
|
"unlockedLink": "unlocked link",
|
||||||
|
"unlockedHint": "Bypasses the instance rules. Anyone with it can join. Not recommended.",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,5 +82,16 @@
|
|||||||
"appName": "VRC Circle",
|
"appName": "VRC Circle",
|
||||||
"version": "バージョン",
|
"version": "バージョン",
|
||||||
"disclaimer": "非公式です。VRChat Inc. とは提携しておらず、承認も受けていません。"
|
"disclaimer": "非公式です。VRChat Inc. とは提携しておらず、承認も受けていません。"
|
||||||
|
},
|
||||||
|
"region": {
|
||||||
|
"title": "インスタンスのリージョン",
|
||||||
|
"description": "作成する新しいインスタンスのホスト地域です。自動を選ぶと、接続に最も遅延の少ないリージョンが選ばれます。",
|
||||||
|
"auto": "自動",
|
||||||
|
"autoHint": "最適なリージョンを検出",
|
||||||
|
"saved": "リージョンを保存しました。",
|
||||||
|
"test": "遅延をテスト",
|
||||||
|
"testing": "リージョンをping中…",
|
||||||
|
"unreachable": "接続不可",
|
||||||
|
"best": "最適"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"createInstance": "インスタンスを作成",
|
||||||
|
"create": {
|
||||||
|
"title": "インスタンスを作成",
|
||||||
|
"type": "アクセス",
|
||||||
|
"region": "リージョン",
|
||||||
|
"submit": "作成",
|
||||||
|
"creating": "作成中…",
|
||||||
|
"detecting": "最適なリージョンを検出中…",
|
||||||
|
"autoDetected": "自動: {{region}}",
|
||||||
|
"types": {
|
||||||
|
"public": "パブリック",
|
||||||
|
"friends+": "フレンド+",
|
||||||
|
"friends": "フレンド",
|
||||||
|
"invite": "インバイト",
|
||||||
|
"invite+": "インバイト+"
|
||||||
|
},
|
||||||
|
"regions": {
|
||||||
|
"us": "米国西部",
|
||||||
|
"use": "米国東部",
|
||||||
|
"eu": "ヨーロッパ",
|
||||||
|
"jp": "日本"
|
||||||
|
},
|
||||||
|
"ready": "インスタンスを作成しました。",
|
||||||
|
"launch": "VRChatを起動",
|
||||||
|
"launching": "起動中…",
|
||||||
|
"selfInvite": "自分を招待",
|
||||||
|
"selfInviteSent": "招待を送信しました。VRChatを確認してください",
|
||||||
|
"lockedLink": "ロック付きリンク",
|
||||||
|
"lockedHint": "インスタンスのルールに従います。共有しても安全です。",
|
||||||
|
"unlockedLink": "ロックなしリンク",
|
||||||
|
"unlockedHint": "インスタンスのルールを無視します。リンクを持つ誰でも参加できます。推奨しません。",
|
||||||
|
"copy": "コピー",
|
||||||
|
"copied": "コピーしました"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,5 +82,16 @@
|
|||||||
"appName": "VRC Circle",
|
"appName": "VRC Circle",
|
||||||
"version": "เวอร์ชัน",
|
"version": "เวอร์ชัน",
|
||||||
"disclaimer": "ไม่เป็นทางการ ไม่ได้มีส่วนเกี่ยวข้องหรือได้รับการรับรองจาก VRChat Inc."
|
"disclaimer": "ไม่เป็นทางการ ไม่ได้มีส่วนเกี่ยวข้องหรือได้รับการรับรองจาก VRChat Inc."
|
||||||
|
},
|
||||||
|
"region": {
|
||||||
|
"title": "ภูมิภาคของอินสแตนซ์",
|
||||||
|
"description": "ภูมิภาคที่ใช้โฮสต์อินสแตนซ์ใหม่ที่คุณสร้าง อัตโนมัติจะเลือกภูมิภาคที่มีความหน่วงต่ำที่สุดสำหรับการเชื่อมต่อของคุณ",
|
||||||
|
"auto": "อัตโนมัติ",
|
||||||
|
"autoHint": "ตรวจหาภูมิภาคที่ดีที่สุด",
|
||||||
|
"saved": "บันทึกภูมิภาคแล้ว",
|
||||||
|
"test": "ทดสอบความหน่วง",
|
||||||
|
"testing": "กำลัง ping ภูมิภาค…",
|
||||||
|
"unreachable": "เชื่อมต่อไม่ได้",
|
||||||
|
"best": "ดีที่สุด"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"createInstance": "สร้างอินสแตนซ์",
|
||||||
|
"create": {
|
||||||
|
"title": "สร้างอินสแตนซ์",
|
||||||
|
"type": "การเข้าถึง",
|
||||||
|
"region": "ภูมิภาค",
|
||||||
|
"submit": "สร้าง",
|
||||||
|
"creating": "กำลังสร้าง…",
|
||||||
|
"detecting": "กำลังตรวจหาภูมิภาคที่ดีที่สุด…",
|
||||||
|
"autoDetected": "อัตโนมัติ: {{region}}",
|
||||||
|
"types": {
|
||||||
|
"public": "สาธารณะ",
|
||||||
|
"friends+": "เพื่อน+",
|
||||||
|
"friends": "เพื่อน",
|
||||||
|
"invite": "เชิญ",
|
||||||
|
"invite+": "เชิญ+"
|
||||||
|
},
|
||||||
|
"regions": {
|
||||||
|
"us": "สหรัฐฯ ฝั่งตะวันตก",
|
||||||
|
"use": "สหรัฐฯ ฝั่งตะวันออก",
|
||||||
|
"eu": "ยุโรป",
|
||||||
|
"jp": "ญี่ปุ่น"
|
||||||
|
},
|
||||||
|
"ready": "สร้างอินสแตนซ์แล้ว",
|
||||||
|
"launch": "เปิด VRChat",
|
||||||
|
"launching": "กำลังเปิด…",
|
||||||
|
"selfInvite": "เชิญตัวเอง",
|
||||||
|
"selfInviteSent": "ส่งคำเชิญแล้ว ตรวจสอบใน VRChat",
|
||||||
|
"lockedLink": "ลิงก์ที่ล็อก",
|
||||||
|
"lockedHint": "เป็นไปตามกฎของอินสแตนซ์ แชร์ได้อย่างปลอดภัย",
|
||||||
|
"unlockedLink": "ลิงก์ที่ปลดล็อก",
|
||||||
|
"unlockedHint": "ข้ามกฎของอินสแตนซ์ ใครก็ตามที่มีลิงก์สามารถเข้าร่วมได้ ไม่แนะนำ",
|
||||||
|
"copy": "คัดลอก",
|
||||||
|
"copied": "คัดลอกแล้ว"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,6 +64,17 @@ export const regionLabels: Record<string, string> = {
|
|||||||
jp: "Japan",
|
jp: "Japan",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const regionFlags: Record<string, string> = {
|
||||||
|
us: "🇺🇸",
|
||||||
|
use: "🇺🇸",
|
||||||
|
eu: "🇪🇺",
|
||||||
|
jp: "🇯🇵",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function regionFlag(region?: string): string | undefined {
|
||||||
|
return region ? regionFlags[region.toLowerCase()] : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const languageNames: Record<string, string> = {
|
const languageNames: Record<string, string> = {
|
||||||
eng: "English",
|
eng: "English",
|
||||||
kor: "Korean",
|
kor: "Korean",
|
||||||
|
|||||||
@@ -21,8 +21,32 @@ export const useWorlds = create<WorldState>((set) => ({
|
|||||||
upsert: (w) => set((st) => ({ worlds: { ...st.worlds, [w.id]: w } })),
|
upsert: (w) => set((st) => ({ worlds: { ...st.worlds, [w.id]: w } })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
let pending: World[] = [];
|
||||||
|
let flushScheduled = false;
|
||||||
|
|
||||||
|
function flushUpserts(): void {
|
||||||
|
flushScheduled = false;
|
||||||
|
if (pending.length === 0) return;
|
||||||
|
const batch = pending;
|
||||||
|
pending = [];
|
||||||
|
useWorlds.setState((st) => {
|
||||||
|
const worlds = { ...st.worlds };
|
||||||
|
for (const w of batch) worlds[w.id] = w;
|
||||||
|
return { worlds };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueUpsert(w: World): void {
|
||||||
|
pending.push(w);
|
||||||
|
if (!flushScheduled) {
|
||||||
|
flushScheduled = true;
|
||||||
|
requestAnimationFrame(flushUpserts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
events.on("world:seed", (s) => useWorlds.getState().seed(s));
|
events.on("world:seed", (s) => useWorlds.getState().seed(s));
|
||||||
events.on("world:upsert", (w) => useWorlds.getState().upsert(w));
|
events.on("world:upsert", queueUpsert);
|
||||||
api.world
|
api.world
|
||||||
.snapshot()
|
.snapshot()
|
||||||
.then((s) => useWorlds.getState().seed(s))
|
.then((s) => useWorlds.getState().seed(s))
|
||||||
|
|||||||
+9
-2
@@ -7,7 +7,7 @@ import type {
|
|||||||
} from "./types/auth";
|
} from "./types/auth";
|
||||||
import type { SocialSnapshot, UserProfile, UserStatus } from "./types/user";
|
import type { SocialSnapshot, UserProfile, UserStatus } from "./types/user";
|
||||||
import type { FavoriteWorldFolder, World, WorldSnapshot } from "./types/world";
|
import type { FavoriteWorldFolder, World, WorldSnapshot } from "./types/world";
|
||||||
import type { Instance } from "./types/instance";
|
import type { CreateInstanceInput, Instance, InstanceRegion } from "./types/instance";
|
||||||
import type { UnityStatus } from "./types/unity";
|
import type { UnityStatus } from "./types/unity";
|
||||||
import type { Avatar } from "./types/avatar";
|
import type { Avatar } from "./types/avatar";
|
||||||
import type { RepoStats, StoredEntity } from "./types/repository";
|
import type { RepoStats, StoredEntity } from "./types/repository";
|
||||||
@@ -15,7 +15,7 @@ import type { AccountSettings, ContentFilterKey, Pending2Fa, RecoveryCode } from
|
|||||||
import type { Group, GroupSnapshot } from "./types/group";
|
import type { Group, GroupSnapshot } from "./types/group";
|
||||||
import type { EnhancementId, EnhancementsSnapshot } from "./types/enhancements";
|
import type { EnhancementId, EnhancementsSnapshot } from "./types/enhancements";
|
||||||
import type { GallerySnapshot, Photo, ThumbCacheStats } from "./types/gallery";
|
import type { GallerySnapshot, Photo, ThumbCacheStats } from "./types/gallery";
|
||||||
import type { AppConfig } from "./types/appConfig";
|
import type { AppConfig, PreferredRegion, RegionPing } from "./types/appConfig";
|
||||||
import type { GameStatus } from "./types/game";
|
import type { GameStatus } from "./types/game";
|
||||||
import type { IpcResult } from "./types/result";
|
import type { IpcResult } from "./types/result";
|
||||||
import type { CacheEntryInfo, CacheStats, DebugSnapshot, LogEntry, WsEvent } from "./types/debug";
|
import type { CacheEntryInfo, CacheStats, DebugSnapshot, LogEntry, WsEvent } from "./types/debug";
|
||||||
@@ -53,6 +53,8 @@ export interface IpcRequests {
|
|||||||
"world:snapshot": () => IpcResult<WorldSnapshot>;
|
"world:snapshot": () => IpcResult<WorldSnapshot>;
|
||||||
|
|
||||||
"instance:get": (location: { worldId: string; instanceId: string }) => IpcResult<Instance>;
|
"instance:get": (location: { worldId: string; instanceId: string }) => IpcResult<Instance>;
|
||||||
|
"instance:create": (input: CreateInstanceInput) => IpcResult<Instance>;
|
||||||
|
"instance:inviteSelf": (p: { worldId: string; instanceId: string }) => IpcResult<void>;
|
||||||
|
|
||||||
"avatar:get": (avatarId: string) => IpcResult<Avatar>;
|
"avatar:get": (avatarId: string) => IpcResult<Avatar>;
|
||||||
"avatar:favorites": () => IpcResult<Avatar[]>;
|
"avatar:favorites": () => IpcResult<Avatar[]>;
|
||||||
@@ -95,12 +97,17 @@ export interface IpcRequests {
|
|||||||
"config:get": () => IpcResult<AppConfig>;
|
"config:get": () => IpcResult<AppConfig>;
|
||||||
"config:setGamePath": (p: { gamePath: string | null }) => IpcResult<AppConfig>;
|
"config:setGamePath": (p: { gamePath: string | null }) => IpcResult<AppConfig>;
|
||||||
"config:pickGamePath": () => IpcResult<AppConfig>;
|
"config:pickGamePath": () => IpcResult<AppConfig>;
|
||||||
|
"config:setPreferredRegion": (p: { region: PreferredRegion }) => IpcResult<AppConfig>;
|
||||||
|
|
||||||
|
"region:detect": () => IpcResult<InstanceRegion>;
|
||||||
|
"region:ping": () => IpcResult<RegionPing[]>;
|
||||||
|
|
||||||
"unity:status": () => IpcResult<UnityStatus>;
|
"unity:status": () => IpcResult<UnityStatus>;
|
||||||
"unity:install": (url: string) => IpcResult<void>;
|
"unity:install": (url: string) => IpcResult<void>;
|
||||||
|
|
||||||
"game:status": () => IpcResult<GameStatus>;
|
"game:status": () => IpcResult<GameStatus>;
|
||||||
"game:launch": () => IpcResult<GameStatus>;
|
"game:launch": () => IpcResult<GameStatus>;
|
||||||
|
"game:join": (p: { location: string }) => IpcResult<void>;
|
||||||
|
|
||||||
"gallery:snapshot": () => IpcResult<GallerySnapshot>;
|
"gallery:snapshot": () => IpcResult<GallerySnapshot>;
|
||||||
"gallery:reveal": (path: string) => IpcResult<void>;
|
"gallery:reveal": (path: string) => IpcResult<void>;
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
|
import type { InstanceRegion } from "./instance";
|
||||||
|
|
||||||
|
export type PreferredRegion = InstanceRegion | "auto";
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
version: string;
|
version: string;
|
||||||
gamePath: string | null;
|
gamePath: string | null;
|
||||||
detectedGamePath: string | null;
|
detectedGamePath: string | null;
|
||||||
|
preferredRegion: PreferredRegion;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegionPing {
|
||||||
|
region: InstanceRegion;
|
||||||
|
ms: number | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,15 @@ export interface Instance {
|
|||||||
full: boolean;
|
full: boolean;
|
||||||
queueEnabled: boolean;
|
queueEnabled: boolean;
|
||||||
queueSize: number;
|
queueSize: number;
|
||||||
|
secureName?: string;
|
||||||
|
shortName?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateInstanceType = "public" | "friends+" | "friends" | "invite" | "invite+";
|
||||||
|
export type InstanceRegion = "us" | "use" | "eu" | "jp";
|
||||||
|
|
||||||
|
export interface CreateInstanceInput {
|
||||||
|
worldId: string;
|
||||||
|
type: CreateInstanceType;
|
||||||
|
region: InstanceRegion;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user