feat: Initial commit

This commit is contained in:
2026-06-27 23:25:12 +07:00
commit c8317eb02a
189 changed files with 20068 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
import type {
AccountsState,
AuthStatus,
CurrentUserSummary,
LoginCredentials,
TwoFactorMethod,
TwoFactorPayload,
} from "../../shared/types/auth";
import { clearLoginClient, createLoginClient, dropClient, getActiveClient } from "./client";
import {
clearPending,
listAccounts,
promotePending,
removeAccount,
setActive,
} from "../accounts/store";
import { toCurrentUserSummary } from "./mappers";
import { userCache } from "./userService";
import { clearSessionCookies, syncSessionCookies } from "./cookies";
import { logger } from "../debug/logger";
import { seedActiveAccount } from "../store/social";
import { entityStore } from "../store/entityStore";
import { repos } from "../store/repository/manager";
interface VRChatLike {
login: (opts: {
username: string;
password: string;
twoFactorCode?: () => Promise<string> | string;
throwOnError?: boolean;
}) => Promise<unknown>;
getCurrentUser: (opts?: { throwOnError?: boolean }) => Promise<{ data?: unknown }>;
logout?: () => Promise<unknown>;
}
type RawUser = Parameters<typeof toCurrentUserSummary>[0];
function isRealUser(data: unknown): data is RawUser {
return (
!!data &&
typeof data === "object" &&
"id" in data &&
"displayName" in data &&
!("requiresTwoFactorAuth" in data)
);
}
async function summaryFrom(vrc: VRChatLike): Promise<CurrentUserSummary> {
const { data } = await vrc.getCurrentUser({ throwOnError: true });
if (!isRealUser(data)) throw new Error("not authenticated");
return toCurrentUserSummary(data);
}
let pendingTwoFactor: {
resolveCode: (code: string) => void;
methods: TwoFactorMethod[];
loginDone: Promise<AuthStatus>;
} | null = null;
async function finalizeLogin(vrc: VRChatLike): Promise<AuthStatus> {
const user = await summaryFrom(vrc);
promotePending({
id: user.id,
displayName: user.displayName,
userIcon: user.userIcon || user.currentAvatarThumbnailImageUrl,
});
clearLoginClient();
userCache.clear();
await syncSessionCookies(vrc);
logger.info("auth", `signed in as ${user.displayName}`);
void seedActiveAccount(true);
return { state: "authenticated", user };
}
export async function checkStatus(): Promise<AuthStatus> {
const vrc = getActiveClient() as unknown as VRChatLike | null;
if (!vrc) return { state: "unauthenticated" };
try {
const user = await summaryFrom(vrc);
await syncSessionCookies(vrc);
void seedActiveAccount();
return { state: "authenticated", user };
} catch {
return { state: "unauthenticated" };
}
}
export async function login(creds: LoginCredentials): Promise<AuthStatus> {
clearPending();
const vrc = createLoginClient() as unknown as VRChatLike;
let resolveCode!: (code: string) => void;
let rejectCode!: (err: unknown) => void;
const codePromise = new Promise<string>((res, rej) => {
resolveCode = res;
rejectCode = rej;
});
let signalAwaiting!: (methods: TwoFactorMethod[]) => void;
const awaiting = new Promise<TwoFactorMethod[]>((res) => {
signalAwaiting = res;
});
const loginDone: Promise<AuthStatus> = vrc
.login({
username: creds.username,
password: creds.password,
throwOnError: true,
twoFactorCode: async () => {
signalAwaiting(["totp", "emailOtp"]);
return codePromise;
},
})
.then(() => finalizeLogin(vrc))
.catch((err) => {
rejectCode(err);
throw err;
});
const winner = await Promise.race([
loginDone.then((status) => ({ kind: "done" as const, status })),
awaiting.then((methods) => ({ kind: "await" as const, methods })),
]);
if (winner.kind === "done") {
pendingTwoFactor = null;
return winner.status;
}
pendingTwoFactor = { resolveCode, methods: winner.methods, loginDone };
return { state: "awaiting2fa", methods: winner.methods };
}
export async function verify2fa(payload: TwoFactorPayload): Promise<AuthStatus> {
if (!pendingTwoFactor) return { state: "unauthenticated" };
const { resolveCode, loginDone } = pendingTwoFactor;
resolveCode(payload.code);
try {
const status = await loginDone;
pendingTwoFactor = null;
return status;
} catch {
pendingTwoFactor = null;
return { state: "unauthenticated" };
}
}
export function listAccountsState(): AccountsState {
return listAccounts();
}
export async function switchAccount(id: string): Promise<AuthStatus> {
setActive(id);
userCache.clear();
logger.info("auth", `switched account → ${id}`);
return checkStatus();
}
export function removeAccountAction(id: string): AccountsState {
dropClient(id);
userCache.clear();
repos.destroy(id);
return removeAccount(id);
}
export async function logout(): Promise<void> {
const id = listAccounts().activeId;
const vrc = getActiveClient() as unknown as VRChatLike | null;
try {
await vrc?.logout?.();
} catch {}
pendingTwoFactor = null;
entityStore.clear();
await clearSessionCookies();
if (id) removeAccountAction(id);
const next = getActiveClient();
if (next) {
await syncSessionCookies(next);
void seedActiveAccount(true);
}
}
+27
View File
@@ -0,0 +1,27 @@
import type { Avatar } from "../../shared/types/avatar";
import { toAvatar } from "./mappers";
import { cachedRead } from "./cachedRead";
import { repos } from "../store/repository/manager";
import { cacheKeys, policies } from "../cache/policies";
export async function getAvatar(avatarId: string): Promise<Avatar> {
const avatar = await cachedRead(cacheKeys.avatar(avatarId), policies.avatar, async (vrc) => {
const { data } = await vrc.getAvatar({ path: { avatarId }, throwOnError: true });
return toAvatar(data);
});
repos.active.avatars.upsert(avatar, "rest:detail");
return avatar;
}
export async function getFavoritedAvatars(): Promise<Avatar[]> {
const avatars = await cachedRead(
cacheKeys.avatarFavorites(),
policies.avatarFavorites,
async (vrc) => {
const { data } = await vrc.getFavoritedAvatars({ query: { n: 100 }, throwOnError: true });
return data.map(toAvatar);
},
);
repos.active.avatars.upsertMany(avatars, "rest:list");
return avatars;
}
+13
View File
@@ -0,0 +1,13 @@
import type { VRChat } from "vrchat";
import type { CachePolicy } from "../cache/cache";
import { requireActiveClient } from "./client";
import { userCache } from "./userService";
export function cachedRead<T>(
key: string,
policy: CachePolicy,
load: (vrc: VRChat) => Promise<T>,
): Promise<T> {
const vrc = requireActiveClient();
return userCache.get(key, policy, () => load(vrc));
}
+68
View File
@@ -0,0 +1,68 @@
import { app } from "electron";
import { KeyvFile } from "keyv-file";
import { VRChat } from "vrchat";
import { activeId, pendingFile, sessionFile } from "../accounts/store";
const APP_META = {
name: "VRC-Circle",
version: app.getVersion(),
contact: "contact@kirameki.cafe",
} as const;
const clients = new Map<string, VRChat>();
let loginClient: VRChat | null = null;
function build(filename: string): VRChat {
const store = new KeyvFile({ filename });
return new VRChat({
application: APP_META,
keyv: store as unknown as ConstructorParameters<typeof VRChat>[0]["keyv"],
authentication: { optimistic: false },
});
}
export function getClient(id: string): VRChat {
let c = clients.get(id);
if (!c) {
c = build(sessionFile(id));
clients.set(id, c);
}
return c;
}
export function getActiveClient(): VRChat | null {
const id = activeId();
return id ? getClient(id) : null;
}
export function requireActiveClient(): VRChat {
const vrc = getActiveClient();
if (!vrc) throw { status: 401, message: "No active account" };
return vrc;
}
export function createLoginClient(): VRChat {
loginClient = build(pendingFile());
return loginClient;
}
export function clearLoginClient(): void {
loginClient = null;
}
export function dropClient(id: string): void {
clients.delete(id);
}
export function closeClients(): void {
for (const client of clients.values()) client.pipeline.close();
loginClient?.pipeline.close();
}
export async function getPipelineAuthToken(vrc: VRChat): Promise<string | null> {
const getCookies = (
vrc as unknown as { getCookies?: () => Promise<{ name: string; value: string }[]> }
).getCookies;
const cookies = (await getCookies?.()) ?? [];
return cookies.find((c) => c.name === "auth")?.value ?? null;
}
+50
View File
@@ -0,0 +1,50 @@
import { session } from "electron";
interface RawCookie {
name: string;
value: string;
expires?: number | null;
}
interface CookieSource {
getCookies?: () => Promise<RawCookie[]>;
}
const AUTH_COOKIES = new Set(["auth", "twoFactorAuth"]);
export async function syncSessionCookies(vrc: unknown): Promise<void> {
const src = vrc as CookieSource | null;
if (!src?.getCookies) return;
let cookies: RawCookie[];
try {
cookies = await src.getCookies();
} catch {
return;
}
const jar = session.defaultSession.cookies;
for (const c of cookies) {
if (!AUTH_COOKIES.has(c.name)) continue;
try {
await jar.set({
url: "https://api.vrchat.cloud",
domain: ".vrchat.cloud",
path: "/",
name: c.name,
value: c.value,
secure: true,
httpOnly: true,
expirationDate: c.expires ? c.expires / 1000 : undefined,
});
} catch {}
}
}
export async function clearSessionCookies(): Promise<void> {
const jar = session.defaultSession.cookies;
for (const name of AUTH_COOKIES) {
try {
await jar.remove("https://api.vrchat.cloud", name);
} catch {}
}
}
+59
View File
@@ -0,0 +1,59 @@
import type { ApiError, ApiErrorCode, IpcResult } from "../../shared/types/result";
interface HttpLike {
status?: number;
statusCode?: number;
status_code?: number;
response?: { status?: number; headers?: Record<string, string> };
headers?: Record<string, string>;
message?: string;
code?: ApiErrorCode;
methods?: ("totp" | "emailOtp")[];
error?: { status_code?: number; statusCode?: number; status?: number };
}
function statusOf(e: HttpLike): number | undefined {
return (
e.status ??
e.statusCode ??
e.status_code ??
e.response?.status ??
e.error?.status_code ??
e.error?.statusCode ??
e.error?.status
);
}
export function httpStatusOf(err: unknown): number | undefined {
return statusOf((err ?? {}) as HttpLike);
}
function retryAfterOf(e: HttpLike): number | undefined {
const raw = e.response?.headers?.["retry-after"] ?? e.headers?.["retry-after"];
const n = raw != null ? Number(raw) : NaN;
return Number.isFinite(n) ? n : undefined;
}
export function toApiError(err: unknown): ApiError {
const e = (err ?? {}) as HttpLike;
const status = statusOf(e);
const message = e.message ?? "Unexpected error";
if (e.code) return { code: e.code, message, methods: e.methods };
let code: ApiErrorCode = "unknown";
if (status === 401) code = "unauthorized";
else if (status === 404) code = "not_found";
else if (status === 429) code = "rate_limited";
else if (status === undefined && /network|fetch|ENOTFOUND|ECONN/i.test(message)) code = "network";
return { code, message, retryAfter: retryAfterOf(e) };
}
export async function guard<T>(fn: () => Promise<T>): Promise<IpcResult<T>> {
try {
return { ok: true, data: await fn() };
} catch (err) {
return { ok: false, error: toApiError(err) };
}
}
+37
View File
@@ -0,0 +1,37 @@
import type { UserProfile } from "../../shared/types/user";
import { requireActiveClient } from "./client";
import { toUserProfile } from "./mappers";
import { currentUser, userCache } from "./userService";
import { cacheKeys, policies } from "../cache/policies";
const order: Record<string, number> = {
"join me": 0,
active: 1,
"ask me": 2,
busy: 3,
offline: 4,
};
export async function listFriends(): Promise<UserProfile[]> {
const vrc = requireActiveClient();
const self = (await currentUser()).id;
return userCache.get(cacheKeys.friends(), policies.friends, async () => {
const [online, offline] = await Promise.all([
vrc.getFriends({ query: { offline: false, n: 100 }, throwOnError: true }),
vrc.getFriends({ query: { offline: true, n: 100 }, throwOnError: true }),
]);
const friends = [
...(online.data ?? []).map((u) => ({ ...toUserProfile(u, self), state: "online" as const })),
...(offline.data ?? []).map((u) => ({
...toUserProfile(u, self),
state: "offline" as const,
})),
];
return friends.sort(
(a, b) =>
(order[a.status] ?? 5) - (order[b.status] ?? 5) ||
a.displayName.localeCompare(b.displayName),
);
});
}
+18
View File
@@ -0,0 +1,18 @@
import type { Group } from "../../shared/types/group";
import { toGroup } from "./mappers";
import { cachedRead } from "./cachedRead";
import { cacheKeys, policies } from "../cache/policies";
export function getUserGroups(userId: string): Promise<Group[]> {
return cachedRead(cacheKeys.userGroups(userId), policies.userGroups, async (vrc) => {
const { data } = await vrc.getUserGroups({ path: { userId }, throwOnError: true });
return data.map(toGroup).filter((g) => g.id);
});
}
export function getRepresentedGroup(userId: string): Promise<Group | null> {
return cachedRead(cacheKeys.representedGroup(userId), policies.representedGroup, async (vrc) => {
const { data } = await vrc.getUserRepresentedGroup({ path: { userId }, throwOnError: true });
return data?.groupId ? toGroup(data) : null;
});
}
+256
View File
@@ -0,0 +1,256 @@
import type {
World as SdkWorld,
LimitedWorld,
FavoritedWorld,
LimitedUserGroups,
RepresentedGroup,
User,
CurrentUser,
LimitedUserFriend,
} from "vrchat";
import type { TrustRank, UserProfile, UserStatus } from "../../shared/types/user";
import type { CurrentUserSummary } from "../../shared/types/auth";
import type { ReleaseStatus, World, WorldPlatforms } from "../../shared/types/world";
import type { Avatar } from "../../shared/types/avatar";
import type { Group } from "../../shared/types/group";
interface RawUser {
id: string;
displayName: string;
bio?: string;
bioLinks?: string[];
statusDescription?: string;
status?: string;
tags?: string[];
userIcon?: string;
profilePicOverride?: string;
profilePicOverrideThumbnail?: string;
currentAvatarImageUrl?: string;
currentAvatarThumbnailImageUrl?: string;
currentAvatarTags?: string[];
location?: string;
lastPlatform?: string;
last_platform?: string;
lastLogin?: string;
last_login?: string | Date | null;
lastActivity?: string;
last_activity?: string | Date | null;
state?: string;
platform?: string;
isFriend?: boolean;
friendKey?: string;
developerType?: string;
ageVerificationStatus?: string;
ageVerified?: boolean;
pronouns?: string;
date_joined?: string | Date;
dateJoined?: string;
note?: string;
pastDisplayNames?: { displayName: string; updated_at?: string | Date }[];
badges?: {
badgeId: string;
badgeName: string;
badgeDescription: string;
badgeImageUrl: string;
showcased?: boolean;
}[];
}
type Mappable = Omit<User | CurrentUser | LimitedUserFriend, "last_login" | "last_activity">;
const _rawUserCheck = (u: Mappable & { id: string; displayName: string }): RawUser => u;
void _rawUserCheck;
// VRChat's trust tag names lag behind the labels shown in-app.
export function trustRankFromTags(tags: string[] = []): TrustRank {
const has = (t: string) => tags.includes(`system_trust_${t}`);
if (tags.includes("system_troll") || tags.includes("system_probable_troll")) return "troll";
if (has("legend")) return "veteran";
if (has("veteran")) return "trusted";
if (has("trusted")) return "known";
if (has("known")) return "user";
if (has("basic")) return "new";
return "visitor";
}
function languagesFromTags(tags: string[] = []): string[] {
return tags.filter((t) => t.startsWith("language_")).map((t) => t.slice("language_".length));
}
function normalizeStatus(status?: string): UserStatus {
switch (status) {
case "join me":
case "active":
case "ask me":
case "busy":
case "offline":
return status;
default:
return "offline";
}
}
function toIso(v?: string | Date | null): string | undefined {
if (!v) return undefined;
const s = v instanceof Date ? v.toISOString() : v;
return s === "" ? undefined : s;
}
export function toUserProfile(raw: RawUser, selfId: string): UserProfile {
const tags = raw.tags ?? [];
return {
id: raw.id,
displayName: raw.displayName,
bio: raw.bio ?? "",
bioLinks: raw.bioLinks ?? [],
statusDescription: raw.statusDescription ?? "",
status: normalizeStatus(raw.status),
trustRank: trustRankFromTags(tags),
tags,
userIcon: raw.userIcon ?? "",
profilePicOverride: raw.profilePicOverride ?? "",
profilePicOverrideThumbnail: raw.profilePicOverrideThumbnail ?? "",
currentAvatarImageUrl: raw.currentAvatarImageUrl ?? "",
currentAvatarThumbnailImageUrl: raw.currentAvatarThumbnailImageUrl ?? "",
currentAvatarTags: raw.currentAvatarTags ?? [],
location: raw.location,
lastPlatform: raw.lastPlatform ?? raw.last_platform,
lastLogin: toIso(raw.lastLogin ?? raw.last_login),
lastActivity: toIso(raw.lastActivity ?? raw.last_activity),
state: raw.state as UserProfile["state"],
platform: raw.platform,
isFriend: raw.isFriend ?? false,
friendKey: raw.friendKey,
developerType: raw.developerType,
ageVerificationStatus: raw.ageVerificationStatus,
ageVerified: raw.ageVerified,
pronouns: raw.pronouns,
languages: languagesFromTags(tags),
dateJoined: raw.dateJoined ?? toIso(raw.date_joined),
pastDisplayNames: raw.pastDisplayNames?.map((p) => ({
displayName: p.displayName,
updatedAt: toIso(p.updated_at),
})),
note: raw.note || undefined,
badges:
raw.badges?.map((b) => ({
id: b.badgeId,
name: b.badgeName,
description: b.badgeDescription,
imageUrl: b.badgeImageUrl,
showcased: b.showcased ?? false,
})) ?? [],
isSelf: raw.id === selfId,
};
}
export function toCurrentUserSummary(raw: RawUser): CurrentUserSummary {
return {
id: raw.id,
displayName: raw.displayName,
userIcon: raw.userIcon ?? "",
currentAvatarThumbnailImageUrl: raw.currentAvatarThumbnailImageUrl ?? "",
};
}
type RawWorld = SdkWorld | LimitedWorld | FavoritedWorld;
export function toWorld(raw: RawWorld): World {
const platforms = raw.unityPackages ? platformsOf(raw.unityPackages) : undefined;
const detailed = "visits" in raw;
return {
id: raw.id,
detailed,
name: raw.name,
authorId: raw.authorId ?? "",
authorName: raw.authorName,
description: "description" in raw ? (raw.description ?? "") : "",
imageUrl: raw.imageUrl ?? "",
thumbnailImageUrl: raw.thumbnailImageUrl ?? "",
releaseStatus: (raw.releaseStatus as ReleaseStatus) ?? "private",
capacity: raw.capacity ?? 0,
favorites: raw.favorites ?? 0,
visits: "visits" in raw ? (raw.visits ?? 0) : 0,
occupants: raw.occupants ?? 0,
heat: raw.heat ?? 0,
tags: raw.tags ?? [],
createdAt: toIso(raw.created_at),
updatedAt: toIso(raw.updated_at),
recommendedCapacity: raw.recommendedCapacity,
popularity: raw.popularity,
version: "version" in raw ? raw.version : undefined,
publishedAt: validDate(raw.publicationDate),
labsPublishedAt: validDate(raw.labsPublicationDate),
previewYoutubeId: raw.previewYoutubeId ?? undefined,
platforms,
publicOccupants: "publicOccupants" in raw ? raw.publicOccupants : undefined,
privateOccupants: "privateOccupants" in raw ? raw.privateOccupants : undefined,
};
}
export function toGroup(raw: LimitedUserGroups | RepresentedGroup): Group {
return {
id: raw.groupId ?? "",
name: raw.name ?? "",
shortCode: raw.shortCode ?? undefined,
description: raw.description || undefined,
iconUrl: raw.iconUrl ?? undefined,
bannerUrl: raw.bannerUrl ?? undefined,
ownerId: raw.ownerId ?? undefined,
memberCount: raw.memberCount,
privacy: raw.privacy ?? undefined,
isRepresenting: raw.isRepresenting ?? undefined,
};
}
function platformsOf(pkgs: ReadonlyArray<{ platform: string }>): WorldPlatforms {
let pc = false;
let android = false;
for (const p of pkgs) {
if (p.platform === "standalonewindows") pc = true;
else if (p.platform === "android") android = true;
}
return { pc, android };
}
function validDate(v?: string): string | undefined {
if (!v || v === "none") return undefined;
return toIso(v);
}
interface RawAvatar {
id: string;
name: string;
authorId?: string;
authorName?: string;
description?: string;
imageUrl?: string;
thumbnailImageUrl?: string;
releaseStatus?: string;
tags?: string[];
favorites?: number;
created_at?: string | Date;
updated_at?: string | Date;
}
export function toAvatar(raw: RawAvatar): Avatar {
return {
id: raw.id,
name: raw.name,
authorId: raw.authorId ?? "",
authorName: raw.authorName ?? "",
description: raw.description ?? "",
imageUrl: raw.imageUrl ?? "",
thumbnailImageUrl: raw.thumbnailImageUrl ?? "",
releaseStatus: raw.releaseStatus ?? "private",
tags: raw.tags ?? [],
favorites: raw.favorites ?? 0,
createdAt: toIso(raw.created_at),
updatedAt: toIso(raw.updated_at),
};
}
+39
View File
@@ -0,0 +1,39 @@
import type { VRChat, FavoritedWorld } from "vrchat";
// VRChat web routes that are missing from the SDK.
interface FavoriteGroupItem {
favoriteId: string;
id: string;
tags: string[];
type: string;
world: FavoritedWorld;
}
interface FavoriteGroupItems {
favorites: FavoriteGroupItem[];
totalCount: number;
}
export type WorldFavoriteGroupType = "world" | "vrcPlusWorld";
export async function getFavoriteGroupWorlds(
vrc: VRChat,
groupType: WorldFavoriteGroupType,
groupName: string,
ownerId: string,
): Promise<FavoritedWorld[]> {
const worlds: FavoritedWorld[] = [];
const pageSize = 100;
for (let offset = 0; ; offset += pageSize) {
const { data } = await vrc.client.get<FavoriteGroupItems, unknown, true>({
url: `/favorites/groups/${groupType}/${encodeURIComponent(groupName)}`,
query: { ownerId, n: pageSize, offset },
throwOnError: true,
});
const page = data.favorites ?? [];
for (const f of page) worlds.push(f.world);
if (page.length < pageSize || worlds.length >= (data.totalCount ?? worlds.length)) break;
}
return worlds;
}
+208
View File
@@ -0,0 +1,208 @@
import type { CurrentUser, VRChat } from "vrchat";
import type {
AccountSettings,
ContentFilterKey,
Pending2Fa,
RecoveryCode,
} from "../../shared/types/settings";
import type { TwoFactorMethod } from "../../shared/types/auth";
import type { UserStatus } from "../../shared/types/user";
import { requireActiveClient } from "./client";
import { userCache } from "./userService";
import { cacheKeys } from "../cache/policies";
import { entityStore } from "../store/entityStore";
const CONTENT_FILTER_KEYS: ContentFilterKey[] = [
"content_sex",
"content_adult",
"content_violence",
"content_gore",
"content_horror",
];
function toIso(d?: Date | string | null): string | undefined {
if (!d) return undefined;
const date = typeof d === "string" ? new Date(d) : d;
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
}
function toSettings(u: CurrentUser): AccountSettings {
const filters = (u.contentFilters ?? []).filter((t): t is ContentFilterKey =>
(CONTENT_FILTER_KEYS as string[]).includes(t),
);
const lastPast = [...(u.pastDisplayNames ?? [])].sort(
(a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(),
)[0];
return {
id: u.id,
displayName: u.displayName,
displayNameChangedAt: toIso(lastPast?.updated_at),
previousDisplayName: lastPast?.displayName,
supporter: (u.tags ?? []).includes("system_supporter"),
pronouns: u.pronouns ?? "",
email: u.obfuscatedEmail ?? "",
emailVerified: u.emailVerified,
pendingEmail: u.hasPendingEmail ? (u.obfuscatedPendingEmail ?? undefined) : undefined,
twoFactorEnabled: u.twoFactorAuthEnabled,
twoFactorEnabledDate: toIso(u.twoFactorAuthEnabledDate),
usesGeneratedPassword: u.usesGeneratedPassword,
ageVerificationStatus: u.ageVerificationStatus,
ageVerified: u.ageVerified,
isAdult: u.isAdult,
contentFilters: filters,
contentFiltersLocked: u.hideContentFilterSettings ?? false,
sharedConnectionsHidden: Boolean(
(u as { hasSharedConnectionsOptOut?: boolean }).hasSharedConnectionsOptOut,
),
discordFriendsHidden: Boolean(
(u as { hasDiscordFriendsOptOut?: boolean }).hasDiscordFriendsOptOut,
),
discord: { linked: Boolean(u.discordId), label: u.discordDetails?.global_name },
google: { linked: Boolean(u.googleId) },
accountDeletionDate: toIso(u.accountDeletionDate) ?? null,
};
}
function invalidateSelf(): void {
userCache.invalidate(cacheKeys.currentUser());
}
function stepUpIfNeeded(data: unknown): void {
if (!data || typeof data !== "object" || !("requiresTwoFactorAuth" in data)) return;
const raw = (data as { requiresTwoFactorAuth?: string[] }).requiresTwoFactorAuth ?? [];
const methods: TwoFactorMethod[] = [];
if (raw.some((m) => m.toLowerCase() === "totp" || m.toLowerCase() === "otp"))
methods.push("totp");
if (raw.some((m) => m.toLowerCase() === "emailotp")) methods.push("emailOtp");
throw {
code: "requires_2fa",
message: "Enter your two-factor code to continue.",
methods: methods.length ? methods : (["totp"] as TwoFactorMethod[]),
};
}
export async function reverify2fa(
method: TwoFactorMethod,
code: string,
): Promise<{ verified: boolean }> {
const vrc = requireActiveClient();
const { data } =
method === "emailOtp"
? await vrc.verify2FaEmailCode({ body: { code }, throwOnError: true })
: await vrc.verify2Fa({ body: { code }, throwOnError: true });
return { verified: data.verified };
}
async function fetchCurrentUser(vrc: VRChat): Promise<CurrentUser> {
const { data } = await vrc.getCurrentUser({ throwOnError: true });
if ("requiresTwoFactorAuth" in data) throw { status: 401, message: "Session expired" };
return data;
}
export async function getSettings(): Promise<AccountSettings> {
return toSettings(await fetchCurrentUser(requireActiveClient()));
}
type UpdateBody = Parameters<VRChat["updateUser"]>[0]["body"];
async function patch(body: UpdateBody): Promise<AccountSettings> {
const vrc = requireActiveClient();
const me = await fetchCurrentUser(vrc);
const { data } = await vrc.updateUser({ path: { userId: me.id }, body, throwOnError: true });
invalidateSelf();
return toSettings(data);
}
export function setDisplayName(
displayName: string,
currentPassword: string,
): Promise<AccountSettings> {
return patch({ displayName, currentPassword });
}
export function revertDisplayName(currentPassword: string): Promise<AccountSettings> {
return patch({ revertDisplayName: true, currentPassword });
}
export function setEmail(email: string, currentPassword: string): Promise<AccountSettings> {
return patch({ email, currentPassword });
}
export function setPassword(
currentPassword: string,
newPassword: string,
): Promise<AccountSettings> {
return patch({ currentPassword, password: newPassword });
}
export function setPrivacy(p: {
sharedConnectionsHidden?: boolean;
discordFriendsHidden?: boolean;
}): Promise<AccountSettings> {
const body: Record<string, boolean> = {};
if (p.sharedConnectionsHidden !== undefined)
body.hasSharedConnectionsOptOut = p.sharedConnectionsHidden;
if (p.discordFriendsHidden !== undefined) body.hasDiscordFriendsOptOut = p.discordFriendsHidden;
return patch(body as UpdateBody);
}
export async function setPresence(status: UserStatus, statusDescription: string): Promise<void> {
const vrc = requireActiveClient();
const me = await fetchCurrentUser(vrc);
await vrc.updateUser({
path: { userId: me.id },
body: { status: status as never, statusDescription },
throwOnError: true,
});
invalidateSelf();
entityStore.upsertFrom({ id: me.id, status, statusDescription }, "ws", Date.now());
}
export function setContentFilters(filters: ContentFilterKey[]): Promise<AccountSettings> {
const ordered = CONTENT_FILTER_KEYS.filter((k) => filters.includes(k));
return patch({ contentFilters: ordered });
}
export async function beginTwoFactorSetup(): Promise<Pending2Fa> {
const vrc = requireActiveClient();
const { data } = await vrc.enable2Fa({ throwOnError: true });
return { secret: data.secret, qrCodeDataUrl: data.qrCodeDataUrl };
}
export async function verifyTwoFactorSetup(code: string): Promise<{ verified: boolean }> {
const vrc = requireActiveClient();
const { data } = await vrc.verifyPending2Fa({ body: { code }, throwOnError: true });
if (data.verified) invalidateSelf();
return { verified: data.verified };
}
export async function disableTwoFactor(): Promise<AccountSettings> {
const vrc = requireActiveClient();
const { data } = await vrc.disable2Fa({ throwOnError: true });
stepUpIfNeeded(data);
if (!data.removed) throw { status: 400, message: "VRChat did not remove two-factor auth." };
invalidateSelf();
const settings = toSettings(await fetchCurrentUser(vrc));
return { ...settings, twoFactorEnabled: false, twoFactorEnabledDate: undefined };
}
export async function getRecoveryCodes(): Promise<RecoveryCode[]> {
const vrc = requireActiveClient();
const { data } = await vrc.getRecoveryCodes({ throwOnError: true });
stepUpIfNeeded(data);
return (data.otp ?? []).map((o) => ({ code: o.code, used: o.used }));
}
export async function resetUserData(): Promise<void> {
const vrc = requireActiveClient();
const me = await fetchCurrentUser(vrc);
await vrc.deleteAllUserPersistenceData({ path: { userId: me.id }, throwOnError: true });
}
export async function deleteAccount(): Promise<AccountSettings> {
const vrc = requireActiveClient();
const me = await fetchCurrentUser(vrc);
const { data } = await vrc.deleteUser({ path: { userId: me.id }, throwOnError: true });
invalidateSelf();
return toSettings(data);
}
+65
View File
@@ -0,0 +1,65 @@
import type { UserProfile } from "../../shared/types/user";
import { requireActiveClient } from "./client";
import { toUserProfile } from "./mappers";
import { TtlCache } from "../cache/cache";
import { cacheKeys, policies } from "../cache/policies";
import { entityStore } from "../store/entityStore";
export const userCache = new TtlCache();
async function selfId(): Promise<string> {
return (await currentUser()).id;
}
function dropPresence(p: UserProfile): Partial<UserProfile> & { id: string } {
const { state: _s, location: _l, status: _st, ...rest } = p;
return rest;
}
function refreshStore(p: UserProfile, key: string): void {
const known = !p.isSelf && entityStore.get(p.id)?.isFriend;
const stamped = p.isSelf || known ? dropPresence(p) : p;
entityStore.upsertFrom(stamped, "rest:detail", userCache.createdAt(key) ?? Date.now());
}
export async function currentUser(): Promise<UserProfile> {
const vrc = requireActiveClient();
const key = cacheKeys.currentUser();
const profile = await userCache.get(key, policies.currentUser, async () => {
const { data } = await vrc.getCurrentUser({ throwOnError: true });
if (!("id" in data)) throw { status: 401, message: "Not authenticated" };
return toUserProfile(data, data.id);
});
refreshStore(profile, key);
return profile;
}
export async function getUser(userId: string): Promise<UserProfile> {
const vrc = requireActiveClient();
const self = await selfId();
const key = cacheKeys.user(userId);
const profile = await userCache.get(key, policies.user, async () => {
const { data } = await vrc.getUser({ path: { userId }, throwOnError: true });
return toUserProfile(data, self);
});
refreshStore(profile, key);
return profile;
}
export async function getUserByName(username: string): Promise<UserProfile> {
const vrc = requireActiveClient();
const self = await selfId();
return userCache.get(cacheKeys.userByName(username), policies.user, async () => {
const { data } = await vrc.getUserByName({ path: { username }, throwOnError: true });
return toUserProfile(data, self);
});
}
export async function searchUsers(query: string): Promise<UserProfile[]> {
const vrc = requireActiveClient();
const self = await selfId();
return userCache.get(cacheKeys.userSearch(query), policies.userSearch, async () => {
const { data } = await vrc.searchUsers({ query: { search: query, n: 25 }, throwOnError: true });
return data.map((u) => toUserProfile(u, self));
});
}
+148
View File
@@ -0,0 +1,148 @@
import type { VRChat } from "vrchat";
import type { FavoriteWorldFolder, World } from "../../shared/types/world";
import { httpStatusOf } from "./errors";
import { getFavoriteGroupWorlds, type WorldFavoriteGroupType } from "./rawEndpoints";
import { toWorld } from "./mappers";
import { cachedRead } from "./cachedRead";
import { worldStore } from "../store/worldStore";
import { broadcast } from "../windows";
import { cacheKeys, policies } from "../cache/policies";
export async function getWorld(worldId: string): Promise<World> {
const world = await cachedRead(cacheKeys.world(worldId), policies.world, async (vrc) => {
const { data } = await vrc.getWorld({ path: { worldId }, throwOnError: true });
return toWorld(data);
});
worldStore.addWorld(world);
return world;
}
type CachedFavorites = { worlds: World[]; folders: FavoriteWorldFolder[] };
export async function getFavoriteWorlds(userId: string): Promise<FavoriteWorldFolder[]> {
const { worlds, folders } = await cachedRead<CachedFavorites>(
cacheKeys.favoriteWorlds(userId),
policies.favoriteWorlds,
(vrc) => loadFavoriteWorlds(vrc, userId),
);
for (const w of worlds) worldStore.addWorld(w);
broadcast("world:favoriteFolders", { userId, folders, done: true });
return folders;
}
async function loadFavoriteWorlds(vrc: VRChat, userId: string): Promise<CachedFavorites> {
let groups;
try {
const { data } = await vrc.getFavoriteGroups({
query: { ownerId: userId, n: 100 },
throwOnError: true,
});
groups = data.filter((g): g is (typeof data)[number] & { type: WorldFavoriteGroupType } =>
isWorldGroupType(g.type),
);
} catch (err) {
if (isPrivateFavorites(err)) return { worlds: [], folders: [] };
throw err;
}
const worlds: World[] = [];
const seen = new Set<string>();
const members: { id: string; group: string }[] = [];
const names = new Map<string, string>();
for (const group of groups) {
if (group.displayName) names.set(group.name, group.displayName);
let raw;
try {
raw = await getFavoriteGroupWorlds(vrc, group.type, group.name, userId);
} catch (err) {
if (isPrivateFavorites(err)) continue;
throw err;
}
for (const rawWorld of raw) {
members.push({ id: rawWorld.id, group: group.name });
if (!seen.has(rawWorld.id)) {
seen.add(rawWorld.id);
const world = toWorld(rawWorld);
worlds.push(world);
worldStore.addWorld(world);
}
}
broadcast("world:favoriteFolders", {
userId,
folders: groupIntoFolders(members, names),
done: false,
});
}
const folders = groupIntoFolders(members, names);
broadcast("world:favoriteFolders", { userId, folders, done: true });
return { worlds, folders };
}
function isWorldGroupType(type: string): type is WorldFavoriteGroupType {
return type === "world" || type === "vrcPlusWorld";
}
function isPrivateFavorites(err: unknown): boolean {
const status = httpStatusOf(err);
return status === 401 || status === 403;
}
function groupIntoFolders(
members: { id: string; group: string }[],
names: Map<string, string>,
): FavoriteWorldFolder[] {
const order: string[] = [];
const byGroup = new Map<string, string[]>();
for (const { id, group } of members) {
const ids = byGroup.get(group);
if (ids) ids.push(id);
else {
byGroup.set(group, [id]);
order.push(group);
}
}
return order.map((name) => ({
name,
displayName: names.get(name) ?? prettyFolderName(name),
worldIds: byGroup.get(name)!,
}));
}
function prettyFolderName(key: string): string {
const m = /^worlds(\d+)$/.exec(key);
if (m) return `Group ${m[1]}`;
return key.charAt(0).toUpperCase() + key.slice(1);
}
export async function searchWorlds(query: string): Promise<World[]> {
const worlds = await cachedRead(
cacheKeys.worldSearch(query),
policies.worldSearch,
async (vrc) => {
const { data } = await vrc.searchWorlds({
query: { search: query, n: 25, sort: "relevance" as const },
throwOnError: true,
});
return data.map(toWorld);
},
);
for (const w of worlds) worldStore.addWorld(w);
return worlds;
}
export async function getUserWorlds(userId: string, isSelf: boolean): Promise<World[]> {
const worlds = await cachedRead(
cacheKeys.userWorlds(userId),
policies.userWorlds,
async (vrc) => {
const query = isSelf
? { user: "me" as const, releaseStatus: "all" as const, n: 50, sort: "updated" as const }
: { userId, releaseStatus: "public" as const, n: 50, sort: "updated" as const };
const { data } = await vrc.searchWorlds({ query, throwOnError: true });
return data.map(toWorld);
},
);
worldStore.setAuthorWorlds(userId, worlds);
return worlds;
}