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 { 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());
|
||||
}
|
||||
|
||||
@@ -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 { 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));
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user