feat: Create Instance, Auto Instance Region

This commit is contained in:
2026-06-28 23:31:02 +07:00
parent 23748b15ae
commit d9ed86fe77
24 changed files with 865 additions and 19 deletions
+12 -2
View File
@@ -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());
} }
+105
View File
@@ -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();
}
+2
View File
@@ -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));
+21
View File
@@ -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)),
+46 -1
View File
@@ -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 });
}
+2
View File
@@ -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,
}; };
} }
+32 -2
View File
@@ -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 (
<span ref={ref} className="block size-full bg-surface-hover">
{visible && src ? (
<img <img
alt={alt} alt={alt}
className={`size-full object-cover transition-transform duration-300 group-hover:scale-105 ${className}`} 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} {...props}
/> />
) : null}
</span>
); );
} }
+4 -2
View File
@@ -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>
);
}
+17 -2
View File
@@ -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
+11
View File
@@ -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": "คัดลอกแล้ว"
}
}
+11
View File
@@ -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",
+25 -1
View File
@@ -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
View File
@@ -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>;
+10
View File
@@ -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;
} }
+11
View File
@@ -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;
} }