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 { 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<AppConfig, "version" | "detectedGamePath">;
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());
}
+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 { 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));
+21
View File
@@ -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)),
+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 { 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<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,
queueEnabled: raw.queueEnabled ?? false,
queueSize: raw.queueSize ?? 0,
secureName: raw.secureName ?? undefined,
shortName: raw.shortName ?? undefined,
};
}