From d9ed86fe7729958735760e3811d61ac43deb7335 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Sun, 28 Jun 2026 23:31:02 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Create=20Instance,=20Auto?= =?UTF-8?q?=20Instance=20Region?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/config/appConfig.ts | 14 +- src/main/game/region.ts | 105 +++++++ src/main/index.ts | 2 + src/main/ipc/handlers.ts | 21 ++ src/main/vrchat/instanceService.ts | 47 ++- src/main/vrchat/mappers.ts | 2 + src/renderer/src/components/ui/HoverImage.tsx | 42 ++- src/renderer/src/components/ui/Modal.tsx | 6 +- .../src/features/profile/LocationSection.tsx | 10 +- .../src/features/settings/SettingsView.tsx | 112 ++++++- .../features/world/CreateInstanceModal.tsx | 283 ++++++++++++++++++ src/renderer/src/features/world/WorldView.tsx | 19 +- src/renderer/src/lib/api.ts | 11 + .../src/lib/i18n/locales/en/settings.json | 11 + .../src/lib/i18n/locales/en/world.json | 36 +++ .../src/lib/i18n/locales/ja/settings.json | 11 + .../src/lib/i18n/locales/ja/world.json | 36 +++ .../src/lib/i18n/locales/th/settings.json | 11 + .../src/lib/i18n/locales/th/world.json | 36 +++ src/renderer/src/lib/vrchat.ts | 11 + src/renderer/src/store/worlds.ts | 26 +- src/shared/ipc.ts | 11 +- src/shared/types/appConfig.ts | 10 + src/shared/types/instance.ts | 11 + 24 files changed, 865 insertions(+), 19 deletions(-) create mode 100644 src/main/game/region.ts create mode 100644 src/renderer/src/features/world/CreateInstanceModal.tsx create mode 100644 src/renderer/src/lib/i18n/locales/en/world.json create mode 100644 src/renderer/src/lib/i18n/locales/ja/world.json create mode 100644 src/renderer/src/lib/i18n/locales/th/world.json diff --git a/src/main/config/appConfig.ts b/src/main/config/appConfig.ts index f7c637c..882215c 100644 --- a/src/main/config/appConfig.ts +++ b/src/main/config/appConfig.ts @@ -2,13 +2,13 @@ import { app } from "electron"; import { join } from "node:path"; import { existsSync, readFileSync } from "node:fs"; 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"; const path = () => join(app.getPath("userData"), "app-config.json"); type StoredConfig = Omit; -const DEFAULTS: StoredConfig = { gamePath: null }; +const DEFAULTS: StoredConfig = { gamePath: null, preferredRegion: "auto" }; function readStored(): StoredConfig { try { @@ -26,6 +26,16 @@ export function gamePathOverride(): string | null { 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 { return withDerived(readStored()); } diff --git a/src/main/game/region.ts b/src/main/game/region.ts new file mode 100644 index 0000000..d8774a9 --- /dev/null +++ b/src/main/game/region.ts @@ -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 = { + 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 { + 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 { + 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 { + 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 { + 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(); +} diff --git a/src/main/index.ts b/src/main/index.ts index ecdf5b7..9f6be4a 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -5,6 +5,7 @@ import { reconcile as reconcileEnhancements } from "./enhancements/service"; import { registerGalleryScheme, registerGalleryProtocol } from "./gallery/protocol"; import { startSocialBridge } from "./store/social"; import { startWatcher as startGameWatcher, joinInstance } from "./game/launch"; +import { startRegionDetection } from "./game/region"; import { startGalleryWatch, stopGalleryWatch } from "./gallery/watcher"; import { startDebugBridge } from "./debug/bridge"; import { logger } from "./debug/logger"; @@ -73,6 +74,7 @@ function start(): void { startDebugBridge(); startGameWatcher(); startGalleryWatch(); + startRegionDetection(); createMainWindow(); handleVrchatUrl(vrchatUrlFromArgv(process.argv)); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index d132553..5ffff97 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -16,6 +16,7 @@ import * as gallery from "../gallery/service"; import { thumbStats, clearThumbnails } from "../gallery/thumbnails"; import * as appConfig from "../config/appConfig"; import * as game from "../game/launch"; +import * as region from "../game/region"; import { socialSnapshot } from "../store/social"; import { worldStore } from "../store/worldStore"; import { groupStore } from "../store/groupStore"; @@ -54,6 +55,9 @@ const handlers = { "instance:get": ({ 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:favorites": () => guard(() => avatars.getFavoritedAvatars()), @@ -94,6 +98,19 @@ const handlers = { if (res.canceled || !res.filePaths[0]) return appConfig.getConfig(); 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:install": (url) => @@ -105,6 +122,10 @@ const handlers = { "game:status": () => guard(() => game.status()), "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:reveal": (path) => guard(async () => void shell.showItemInFolder(path)), diff --git a/src/main/vrchat/instanceService.ts b/src/main/vrchat/instanceService.ts index 9d4d89d..c4399e5 100644 --- a/src/main/vrchat/instanceService.ts +++ b/src/main/vrchat/instanceService.ts @@ -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 { cachedRead } from "./cachedRead"; +import { requireActiveClient } from "./client"; +import { currentUser } from "./userService"; import { cacheKeys, policies } from "../cache/policies"; export async function getInstance(worldId: string, instanceId: string): Promise { @@ -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 { + 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 { + const vrc = requireActiveClient(); + await vrc.inviteMyselfTo({ path: { worldId, instanceId }, throwOnError: true }); +} diff --git a/src/main/vrchat/mappers.ts b/src/main/vrchat/mappers.ts index bfe94fc..a327473 100644 --- a/src/main/vrchat/mappers.ts +++ b/src/main/vrchat/mappers.ts @@ -211,6 +211,8 @@ export function toInstance(raw: SdkInstance): Instance { full: raw.full ?? false, queueEnabled: raw.queueEnabled ?? false, queueSize: raw.queueSize ?? 0, + secureName: raw.secureName ?? undefined, + shortName: raw.shortName ?? undefined, }; } diff --git a/src/renderer/src/components/ui/HoverImage.tsx b/src/renderer/src/components/ui/HoverImage.tsx index 1b9b32f..7f4c28b 100644 --- a/src/renderer/src/components/ui/HoverImage.tsx +++ b/src/renderer/src/components/ui/HoverImage.tsx @@ -1,15 +1,45 @@ -import type { ImgHTMLAttributes } from "react"; +import { useEffect, useRef, useState, type ImgHTMLAttributes } from "react"; export function HoverImage({ className = "", alt = "", + src, ...props }: ImgHTMLAttributes) { + const ref = useRef(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 ( - {alt} + + {visible && src ? ( + {alt} 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} + ); } diff --git a/src/renderer/src/components/ui/Modal.tsx b/src/renderer/src/components/ui/Modal.tsx index 4bad06f..00fdb7b 100644 --- a/src/renderer/src/components/ui/Modal.tsx +++ b/src/renderer/src/components/ui/Modal.tsx @@ -2,6 +2,7 @@ import { useEffect, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { X } from "lucide-react"; import { Button } from "./Button"; +import { useT } from "../../lib/i18n"; type ModalProps = { open: boolean; @@ -25,11 +26,12 @@ export function Modal({ children, danger, confirmLabel, - cancelLabel = "Cancel", + cancelLabel, onConfirm, confirmLoading, confirmDisabled, }: ModalProps) { + const t = useT(); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { @@ -78,7 +80,7 @@ export function Modal({ {onConfirm ? (
+ ); + })} +
+ +
+ +
+ + {pings ? ( +
+ {pings.map((p) => ( +
+ + {regionFlag(p.region) ?? ""} {regionLabels[p.region] ?? p.region.toUpperCase()} + + {p.ms === null ? ( + {t("settings:region.unreachable")} + ) : ( + + {p.ms} ms + + )} + {best?.region === p.region ? ( + + {t("settings:region.best")} + + ) : null} +
+ ))} +
+ ) : null} + + + + ); +} + function UnitySection() { const { t } = useI18n(); const status = useAsync(() => api.unity.status(), [], t("settings:unity.error")); diff --git a/src/renderer/src/features/world/CreateInstanceModal.tsx b/src/renderer/src/features/world/CreateInstanceModal.tsx new file mode 100644 index 0000000..e0ae95c --- /dev/null +++ b/src/renderer/src/features/world/CreateInstanceModal.tsx @@ -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("invite"); + const [region, setRegion] = useState(null); + const [autoRegion, setAutoRegion] = useState(false); + const [detecting, setDetecting] = useState(false); + const [creating, setCreating] = useState(false); + const [instance, setInstance] = useState(null); + const [error, setError] = useState(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 ( + } + confirmLabel={instance ? undefined : t("world:create.submit")} + onConfirm={instance ? undefined : create} + confirmLoading={creating} + confirmDisabled={!instance && !region} + > + {instance ? ( + + ) : ( +
+ {error ? {error} : null} + t(`world:create.types.${v}`)} + /> + `${regionFlag(v) ?? ""} ${t(`world:create.regions.${v}`)}`.trim()} + hint={ + detecting ? ( + + + {t("world:create.detecting")} + + ) : autoRegion && region ? ( + + + {t("world:create.autoDetected", { + region: t(`world:create.regions.${region}`), + })} + + ) : undefined + } + /> +
+ )} +
+ ); +} + +function Picker({ + 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 ( +
+
+ {label} +
+
+ {options.map((opt) => ( + + ))} +
+ {hint ?
{hint}
: null} +
+ ); +} + +function ResultView({ instance }: { instance: Instance }) { + const t = useT(); + const [inviteSent, setInviteSent] = useState(false); + const [inviteError, setInviteError] = useState(null); + const [launching, setLaunching] = useState(false); + const [launchError, setLaunchError] = useState(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 ( +
+

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

+ +
+ + {launchError ? {launchError} : null} +
+ +
+ + {inviteError ? {inviteError} : null} +
+ + {instance.secureName ? ( + + ) : null} + + {instance.shortName ? ( + + ) : null} +
+ ); +} + +function LinkRow({ + label, + hint, + link, + warn, +}: { + label: string; + hint: string; + link: string; + warn?: boolean; +}) { + const t = useT(); + const [copied, copy] = useCopied(); + + return ( +
+ + + {hint} + +
+ ); +} diff --git a/src/renderer/src/features/world/WorldView.tsx b/src/renderer/src/features/world/WorldView.tsx index 8630024..70a9af6 100644 --- a/src/renderer/src/features/world/WorldView.tsx +++ b/src/renderer/src/features/world/WorldView.tsx @@ -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 { 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 { useAsync } from "../../lib/useAsync"; import { compactNumber, formatDate, prettyTag } from "../../lib/format"; import { useWorlds } from "../../store/worlds"; import { useNav } from "../navigation/NavContext"; +import { useT } from "../../lib/i18n"; import { COL_WIDE } from "../../lib/layout"; +import { CreateInstanceModal } from "./CreateInstanceModal"; import "../profile/profile.css"; export function WorldView({ worldId }: { worldId: string }) { @@ -22,6 +25,8 @@ export function WorldView({ worldId }: { worldId: string }) { function WorldCard({ world }: { world: World }) { const { openUser } = useNav(); + const t = useT(); + const [createOpen, setCreateOpen] = useState(false); const banner = world.imageUrl || world.thumbnailImageUrl; const players = world.occupants; @@ -57,8 +62,18 @@ function WorldCard({ world }: { world: World }) { {world.platforms?.android ? Quest : null} + + setCreateOpen(false)} + /> +
call("instance:get", { worldId, instanceId }), + create: (input: CreateInstanceInput) => call("instance:create", input), + inviteSelf: (worldId: string, instanceId: string) => + call("instance:inviteSelf", { worldId, instanceId }), }, avatar: { get: (avatarId: string) => call("avatar:get", avatarId), @@ -104,6 +109,11 @@ export const api = { get: () => call("config:get"), setGamePath: (gamePath: string | null) => call("config:setGamePath", { gamePath }), pickGamePath: () => call("config:pickGamePath"), + setPreferredRegion: (region: PreferredRegion) => call("config:setPreferredRegion", { region }), + }, + region: { + detect: () => call("region:detect"), + ping: () => call("region:ping"), }, unity: { status: () => call("unity:status"), @@ -112,6 +122,7 @@ export const api = { game: { status: () => call("game:status"), launch: () => call("game:launch"), + join: (location: string) => call("game:join", { location }), }, gallery: { snapshot: () => call("gallery:snapshot"), diff --git a/src/renderer/src/lib/i18n/locales/en/settings.json b/src/renderer/src/lib/i18n/locales/en/settings.json index b43bd64..e60dac0 100644 --- a/src/renderer/src/lib/i18n/locales/en/settings.json +++ b/src/renderer/src/lib/i18n/locales/en/settings.json @@ -82,5 +82,16 @@ "appName": "VRC Circle", "version": "Version", "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" } } diff --git a/src/renderer/src/lib/i18n/locales/en/world.json b/src/renderer/src/lib/i18n/locales/en/world.json new file mode 100644 index 0000000..eba085a --- /dev/null +++ b/src/renderer/src/lib/i18n/locales/en/world.json @@ -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" + } +} diff --git a/src/renderer/src/lib/i18n/locales/ja/settings.json b/src/renderer/src/lib/i18n/locales/ja/settings.json index dc8c5f8..98ccf53 100644 --- a/src/renderer/src/lib/i18n/locales/ja/settings.json +++ b/src/renderer/src/lib/i18n/locales/ja/settings.json @@ -82,5 +82,16 @@ "appName": "VRC Circle", "version": "バージョン", "disclaimer": "非公式です。VRChat Inc. とは提携しておらず、承認も受けていません。" + }, + "region": { + "title": "インスタンスのリージョン", + "description": "作成する新しいインスタンスのホスト地域です。自動を選ぶと、接続に最も遅延の少ないリージョンが選ばれます。", + "auto": "自動", + "autoHint": "最適なリージョンを検出", + "saved": "リージョンを保存しました。", + "test": "遅延をテスト", + "testing": "リージョンをping中…", + "unreachable": "接続不可", + "best": "最適" } } diff --git a/src/renderer/src/lib/i18n/locales/ja/world.json b/src/renderer/src/lib/i18n/locales/ja/world.json new file mode 100644 index 0000000..b168c8e --- /dev/null +++ b/src/renderer/src/lib/i18n/locales/ja/world.json @@ -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": "コピーしました" + } +} diff --git a/src/renderer/src/lib/i18n/locales/th/settings.json b/src/renderer/src/lib/i18n/locales/th/settings.json index 3aa014a..2308be0 100644 --- a/src/renderer/src/lib/i18n/locales/th/settings.json +++ b/src/renderer/src/lib/i18n/locales/th/settings.json @@ -82,5 +82,16 @@ "appName": "VRC Circle", "version": "เวอร์ชัน", "disclaimer": "ไม่เป็นทางการ ไม่ได้มีส่วนเกี่ยวข้องหรือได้รับการรับรองจาก VRChat Inc." + }, + "region": { + "title": "ภูมิภาคของอินสแตนซ์", + "description": "ภูมิภาคที่ใช้โฮสต์อินสแตนซ์ใหม่ที่คุณสร้าง อัตโนมัติจะเลือกภูมิภาคที่มีความหน่วงต่ำที่สุดสำหรับการเชื่อมต่อของคุณ", + "auto": "อัตโนมัติ", + "autoHint": "ตรวจหาภูมิภาคที่ดีที่สุด", + "saved": "บันทึกภูมิภาคแล้ว", + "test": "ทดสอบความหน่วง", + "testing": "กำลัง ping ภูมิภาค…", + "unreachable": "เชื่อมต่อไม่ได้", + "best": "ดีที่สุด" } } diff --git a/src/renderer/src/lib/i18n/locales/th/world.json b/src/renderer/src/lib/i18n/locales/th/world.json new file mode 100644 index 0000000..9d54a1d --- /dev/null +++ b/src/renderer/src/lib/i18n/locales/th/world.json @@ -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": "คัดลอกแล้ว" + } +} diff --git a/src/renderer/src/lib/vrchat.ts b/src/renderer/src/lib/vrchat.ts index df10b9b..8039f86 100644 --- a/src/renderer/src/lib/vrchat.ts +++ b/src/renderer/src/lib/vrchat.ts @@ -64,6 +64,17 @@ export const regionLabels: Record = { jp: "Japan", }; +export const regionFlags: Record = { + us: "🇺🇸", + use: "🇺🇸", + eu: "🇪🇺", + jp: "🇯🇵", +}; + +export function regionFlag(region?: string): string | undefined { + return region ? regionFlags[region.toLowerCase()] : undefined; +} + const languageNames: Record = { eng: "English", kor: "Korean", diff --git a/src/renderer/src/store/worlds.ts b/src/renderer/src/store/worlds.ts index 59da1c7..70addc8 100644 --- a/src/renderer/src/store/worlds.ts +++ b/src/renderer/src/store/worlds.ts @@ -21,8 +21,32 @@ export const useWorlds = create((set) => ({ 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:upsert", (w) => useWorlds.getState().upsert(w)); +events.on("world:upsert", queueUpsert); api.world .snapshot() .then((s) => useWorlds.getState().seed(s)) diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 5466c26..ff05f45 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -7,7 +7,7 @@ import type { } from "./types/auth"; import type { SocialSnapshot, UserProfile, UserStatus } from "./types/user"; 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 { Avatar } from "./types/avatar"; 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 { EnhancementId, EnhancementsSnapshot } from "./types/enhancements"; 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 { IpcResult } from "./types/result"; import type { CacheEntryInfo, CacheStats, DebugSnapshot, LogEntry, WsEvent } from "./types/debug"; @@ -53,6 +53,8 @@ export interface IpcRequests { "world:snapshot": () => IpcResult; "instance:get": (location: { worldId: string; instanceId: string }) => IpcResult; + "instance:create": (input: CreateInstanceInput) => IpcResult; + "instance:inviteSelf": (p: { worldId: string; instanceId: string }) => IpcResult; "avatar:get": (avatarId: string) => IpcResult; "avatar:favorites": () => IpcResult; @@ -95,12 +97,17 @@ export interface IpcRequests { "config:get": () => IpcResult; "config:setGamePath": (p: { gamePath: string | null }) => IpcResult; "config:pickGamePath": () => IpcResult; + "config:setPreferredRegion": (p: { region: PreferredRegion }) => IpcResult; + + "region:detect": () => IpcResult; + "region:ping": () => IpcResult; "unity:status": () => IpcResult; "unity:install": (url: string) => IpcResult; "game:status": () => IpcResult; "game:launch": () => IpcResult; + "game:join": (p: { location: string }) => IpcResult; "gallery:snapshot": () => IpcResult; "gallery:reveal": (path: string) => IpcResult; diff --git a/src/shared/types/appConfig.ts b/src/shared/types/appConfig.ts index d89d544..b2da36d 100644 --- a/src/shared/types/appConfig.ts +++ b/src/shared/types/appConfig.ts @@ -1,5 +1,15 @@ +import type { InstanceRegion } from "./instance"; + +export type PreferredRegion = InstanceRegion | "auto"; + export interface AppConfig { version: string; gamePath: string | null; detectedGamePath: string | null; + preferredRegion: PreferredRegion; +} + +export interface RegionPing { + region: InstanceRegion; + ms: number | null; } diff --git a/src/shared/types/instance.ts b/src/shared/types/instance.ts index 3d4f206..397777c 100644 --- a/src/shared/types/instance.ts +++ b/src/shared/types/instance.ts @@ -12,4 +12,15 @@ export interface Instance { full: boolean; queueEnabled: boolean; 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; }