feat: Initial commit

This commit is contained in:
2026-06-25 19:25:00 +07:00
commit c8317eb02a
189 changed files with 20068 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules/
out/
dist/
*.log
*.tsbuildinfo
.DS_Store
.env
.env.*
+7
View File
@@ -0,0 +1,7 @@
out
dist
node_modules
release
coverage
*.log
pnpm-lock.yaml
+6
View File
@@ -0,0 +1,6 @@
{
"printWidth": 100,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}
View File
+16
View File
@@ -0,0 +1,16 @@
appId: cafe.kirameki.vrc-circle
productName: VRC Circle
directories:
output: dist
buildResources: resources
files:
- out/**
- package.json
linux:
target: [AppImage]
category: Network
win:
target: [nsis]
mac:
target: [dmg]
category: public.app-category.social-networking
+25
View File
@@ -0,0 +1,25 @@
import { resolve } from "node:path";
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
main: {
plugins: [externalizeDepsPlugin()],
build: { rollupOptions: { input: resolve("src/main/index.ts") } },
},
preload: {
plugins: [externalizeDepsPlugin()],
build: { rollupOptions: { input: resolve("src/preload/index.ts") } },
},
renderer: {
root: "src/renderer",
plugins: [react(), tailwindcss()],
resolve: {
alias: { "@renderer": resolve("src/renderer/src") },
},
build: {
rollupOptions: { input: resolve("src/renderer/index.html") },
},
},
});
+47
View File
@@ -0,0 +1,47 @@
import js from "@eslint/js";
import tseslint from "typescript-eslint";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
export default tseslint.config(
{ ignores: ["out/**", "dist/**", "node_modules/**", "*.config.js", "*.config.ts"] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2022,
globals: { ...globals.node, ...globals.browser },
},
rules: {
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-explicit-any": "warn",
"no-empty": ["error", { allowEmptyCatch: true }],
"no-console": ["warn", { allow: ["warn", "error"] }],
},
},
// The renderer is sandboxed: it must never import electron or the vrchat SDK.
// It only talks to the main process through window.api (shared/ipc).
{
files: ["src/renderer/**/*.{ts,tsx}"],
plugins: { "react-hooks": reactHooks, "react-refresh": reactRefresh },
rules: {
...reactHooks.configs.recommended.rules,
"react-hooks/set-state-in-effect": "warn",
"react-hooks/refs": "warn",
"no-restricted-imports": [
"error",
{
paths: [
{ name: "electron", message: "renderer must not import electron; use window.api" },
{ name: "vrchat", message: "renderer must not import the vrchat SDK; use window.api" },
],
},
],
},
},
);
+60
View File
@@ -0,0 +1,60 @@
{
"name": "vrc-circle",
"version": "0.1.0",
"description": "An all-in-one VRChat Hub & Launcher",
"author": "YuzuZensai <yuzu@kirameki.cafe>",
"license": "MIT",
"main": "./out/main/index.js",
"type": "module",
"scripts": {
"dev": "electron-vite dev",
"build": "pnpm run typecheck && electron-vite build",
"preview": "electron-vite preview",
"start": "electron-vite preview",
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
"lint": "eslint .",
"format": "prettier --check .",
"format:fix": "prettier --write .",
"package": "pnpm run build && electron-builder --dir",
"dist": "pnpm run build && electron-builder"
},
"dependencies": {
"electron-updater": "^6.3.9",
"i18next": "^26.3.2",
"keyv": "^5.2.3",
"keyv-file": "^5.1.2",
"lucide-react": "^1.21.0",
"react-i18next": "^17.0.8",
"sharp": "^0.35.2",
"vrchat": "^2.1.0",
"ws": "^8.18.0",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.1",
"@types/node": "^22.10.5",
"@types/react": "^19.0.4",
"@types/react-dom": "^19.0.2",
"@types/ws": "^8.5.13",
"@vitejs/plugin-react": "^4.3.4",
"electron": "^33.3.0",
"electron-builder": "^25.1.8",
"electron-vite": "^2.3.0",
"eslint": "^10.6.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0",
"playwright-core": "^1.61.1",
"prettier": "^3.6.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^4.3.1",
"typescript": "^5.7.3",
"typescript-eslint": "^8.62.0",
"vite": "^6.0.7"
},
"packageManager": "pnpm@10.33.0"
}
+5857
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -0,0 +1,4 @@
onlyBuiltDependencies:
- electron
- esbuild
- unrs-resolver
+67
View File
@@ -0,0 +1,67 @@
import { app } from "electron";
import { join } from "node:path";
import { copyFileSync, existsSync, readFileSync, rmSync } from "node:fs";
import { writeFileAtomicSync } from "../lib/atomicFile";
import type { Account, AccountsState } from "../../shared/types/auth";
const dir = () => join(app.getPath("userData"), "sessions");
const registryPath = () => join(dir(), "accounts.json");
export const sessionFile = (id: string) => join(dir(), `${id}.json`);
export const pendingFile = () => join(dir(), "_pending.json");
interface Registry {
active: string | null;
accounts: Account[];
}
function read(): Registry {
try {
return JSON.parse(readFileSync(registryPath(), "utf8")) as Registry;
} catch {
return { active: null, accounts: [] };
}
}
function write(reg: Registry): void {
writeFileAtomicSync(registryPath(), JSON.stringify(reg, null, 2));
}
export function listAccounts(): AccountsState {
const reg = read();
return { accounts: reg.accounts, activeId: reg.active };
}
export function activeId(): string | null {
return read().active;
}
export function setActive(id: string | null): void {
const reg = read();
reg.active = id;
write(reg);
}
export function promotePending(account: Account): void {
if (existsSync(pendingFile())) {
copyFileSync(pendingFile(), sessionFile(account.id));
rmSync(pendingFile(), { force: true });
}
const reg = read();
reg.accounts = [account, ...reg.accounts.filter((a) => a.id !== account.id)];
reg.active = account.id;
write(reg);
}
export function removeAccount(id: string): AccountsState {
rmSync(sessionFile(id), { force: true });
const reg = read();
reg.accounts = reg.accounts.filter((a) => a.id !== id);
if (reg.active === id) reg.active = reg.accounts[0]?.id ?? null;
write(reg);
return { accounts: reg.accounts, activeId: reg.active };
}
export function clearPending(): void {
rmSync(pendingFile(), { force: true });
}
+302
View File
@@ -0,0 +1,302 @@
import { readFileSync } from "node:fs";
import { writeFileAtomic, writeFileAtomicSync } from "../lib/atomicFile";
import type { CacheEntryInfo, CacheStats } from "../../shared/types/debug";
interface Entry<T> {
value: T;
createdAt: number;
expiresAt: number;
hardExpiresAt: number;
hits: number;
lastAccess: number;
size: number;
}
interface Counters {
hits: number;
misses: number;
sets: number;
patches: number;
revalidations: number;
invalidations: number;
clears: number;
}
function byteSize(value: unknown): number {
try {
return new TextEncoder().encode(JSON.stringify(value) ?? "").length;
} catch {
return 0;
}
}
function retryAfterMs(err: unknown): number | null {
const e = (err ?? {}) as {
status?: number;
statusCode?: number;
response?: { status?: number; headers?: Record<string, string> };
headers?: Record<string, string>;
};
const status = e.status ?? e.statusCode ?? e.response?.status;
if (status !== 429) return null;
const raw = e.response?.headers?.["retry-after"] ?? e.headers?.["retry-after"];
const secs = raw != null ? Number(raw) : NaN;
return Number.isFinite(secs) ? secs * 1000 : 8000;
}
export interface CachePolicy {
ttl: number;
staleWhileRevalidate?: number;
}
export type CacheEvent = {
type: "set" | "patch" | "invalidate" | "clear" | "hit" | "revalidate";
key?: string;
};
type Listener = (e: CacheEvent) => void;
export class TtlCache {
private readonly store = new Map<string, Entry<unknown>>();
private readonly inflight = new Map<string, Promise<unknown>>();
private readonly listeners = new Set<Listener>();
private file: string | null = null;
private saveTimer: NodeJS.Timeout | null = null;
private rateLimitedUntil = 0;
constructor(private readonly maxEntries = 500) {}
private readonly counters: Counters = {
hits: 0,
misses: 0,
sets: 0,
patches: 0,
revalidations: 0,
invalidations: 0,
clears: 0,
};
persistTo(file: string): void {
this.file = file;
try {
const raw = JSON.parse(readFileSync(file, "utf8")) as Record<string, Partial<Entry<unknown>>>;
for (const [k, v] of Object.entries(raw)) {
if (v.createdAt == null || v.expiresAt == null || v.hardExpiresAt == null) continue;
this.store.set(k, {
value: v.value,
createdAt: v.createdAt,
expiresAt: v.expiresAt,
hardExpiresAt: v.hardExpiresAt,
hits: v.hits ?? 0,
lastAccess: v.lastAccess ?? 0,
size: v.size ?? byteSize(v.value),
});
}
} catch {
/* start empty */
}
}
createdAt(key: string): number | null {
return this.store.get(key)?.createdAt ?? null;
}
onChange(fn: Listener): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
async get<T>(key: string, policy: CachePolicy, loader: () => Promise<T>): Promise<T> {
const now = Date.now();
const hit = this.store.get(key) as Entry<T> | undefined;
if (hit && now < hit.expiresAt) {
hit.hits++;
hit.lastAccess = now;
this.counters.hits++;
this.emit({ type: "hit", key });
return hit.value;
}
const rateLimited = now < this.rateLimitedUntil;
if (hit && now < hit.hardExpiresAt) {
hit.hits++;
hit.lastAccess = now;
this.counters.hits++;
if (!rateLimited) {
this.counters.revalidations++;
this.emit({ type: "revalidate", key });
void this.revalidate(key, policy, loader);
}
return hit.value;
}
if (rateLimited) {
if (hit) return hit.value;
throw { status: 429, message: "Rate limited; retry later" };
}
this.counters.misses++;
return this.load(key, policy, loader);
}
set<T>(key: string, value: T, policy: CachePolicy): void {
const now = Date.now();
const prev = this.store.get(key);
this.store.set(key, {
value,
createdAt: now,
expiresAt: now + policy.ttl,
hardExpiresAt: now + policy.ttl + (policy.staleWhileRevalidate ?? 0),
hits: prev?.hits ?? 0,
lastAccess: prev?.lastAccess ?? 0,
size: byteSize(value),
});
this.counters.sets++;
this.evictIfNeeded();
this.scheduleSave();
this.emit({ type: "set", key });
}
patch<T extends object>(key: string, partial: Partial<T>): void {
const hit = this.store.get(key) as Entry<T> | undefined;
if (!hit) return;
hit.value = { ...hit.value, ...partial };
hit.size = byteSize(hit.value);
this.counters.patches++;
this.scheduleSave();
this.emit({ type: "patch", key });
}
invalidate(key: string): void {
if (this.store.delete(key)) {
this.counters.invalidations++;
this.scheduleSave();
this.emit({ type: "invalidate", key });
}
}
clear(): void {
this.store.clear();
this.inflight.clear();
this.counters.clears++;
this.scheduleSave();
this.emit({ type: "clear" });
}
entries(): CacheEntryInfo[] {
return [...this.store.entries()].map(([key, e]) => ({
key,
createdAt: e.createdAt,
expiresAt: e.expiresAt,
hardExpiresAt: e.hardExpiresAt,
hits: e.hits,
lastAccess: e.lastAccess,
size: e.size,
value: e.value,
}));
}
stats(): CacheStats {
const now = Date.now();
let fresh = 0;
let stale = 0;
let expired = 0;
let totalSize = 0;
for (const e of this.store.values()) {
totalSize += e.size;
if (now < e.expiresAt) fresh++;
else if (now < e.hardExpiresAt) stale++;
else expired++;
}
return {
entries: this.store.size,
inflight: this.inflight.size,
totalSize,
fresh,
stale,
expired,
...this.counters,
persisted: this.file !== null,
persistFile: this.file,
};
}
private load<T>(key: string, policy: CachePolicy, loader: () => Promise<T>): Promise<T> {
const existing = this.inflight.get(key) as Promise<T> | undefined;
if (existing) return existing;
const p = loader()
.then((value) => {
this.set(key, value, policy);
return value;
})
.catch((err) => {
const ms = retryAfterMs(err);
if (ms != null) this.rateLimitedUntil = Date.now() + ms;
throw err;
})
.finally(() => this.inflight.delete(key));
this.inflight.set(key, p);
return p;
}
private async revalidate<T>(
key: string,
policy: CachePolicy,
loader: () => Promise<T>,
): Promise<void> {
try {
await this.load(key, policy, loader);
} catch {}
}
private emit(e: CacheEvent): void {
for (const fn of this.listeners) fn(e);
}
private scheduleSave(): void {
if (!this.file || this.saveTimer) return;
this.saveTimer = setTimeout(() => {
this.saveTimer = null;
this.flush();
}, 250);
this.saveTimer.unref?.();
}
private evictIfNeeded(): void {
const now = Date.now();
for (const [k, e] of this.store) {
if (now >= e.hardExpiresAt) this.store.delete(k);
}
if (this.store.size <= this.maxEntries) return;
const byAccess = [...this.store.entries()].sort(
(a, b) => (a[1].lastAccess || a[1].createdAt) - (b[1].lastAccess || b[1].createdAt),
);
for (let i = 0; i < byAccess.length && this.store.size > this.maxEntries; i++) {
this.store.delete(byAccess[i][0]);
}
}
private flush(): void {
if (!this.file) return;
this.evictIfNeeded();
const snapshot = JSON.stringify(Object.fromEntries(this.store));
void writeFileAtomic(this.file, snapshot).catch(() => {
/* persistence is best-effort */
});
}
flushNow(): void {
if (!this.file) return;
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
this.evictIfNeeded();
try {
writeFileAtomicSync(this.file, JSON.stringify(Object.fromEntries(this.store)));
} catch {}
}
}
+32
View File
@@ -0,0 +1,32 @@
import type { CachePolicy } from "./cache";
export const policies = {
currentUser: { ttl: 5 * 60_000, staleWhileRevalidate: 10 * 60_000 },
user: { ttl: 5 * 60_000, staleWhileRevalidate: 10 * 60_000 },
userSearch: { ttl: 5 * 60_000 },
worldSearch: { ttl: 5 * 60_000 },
friends: { ttl: 5 * 60_000, staleWhileRevalidate: 60_000 },
userWorlds: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
favoriteWorlds: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
world: { ttl: 30 * 60_000, staleWhileRevalidate: 2 * 60 * 60_000 },
avatar: { ttl: 30 * 60_000, staleWhileRevalidate: 2 * 60 * 60_000 },
avatarFavorites: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
userGroups: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
representedGroup: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
} satisfies Record<string, CachePolicy>;
export const cacheKeys = {
currentUser: () => "user:me",
user: (id: string) => `user:${id}`,
userByName: (name: string) => `user:name:${name.toLowerCase()}`,
userSearch: (q: string) => `user:search:${q.trim().toLowerCase()}`,
worldSearch: (q: string) => `world:search:${q.trim().toLowerCase()}`,
friends: () => "friends",
userWorlds: (id: string) => `user:worlds:${id}`,
favoriteWorlds: (id: string) => `worlds:favorites:${id}`,
world: (id: string) => `world:${id}`,
avatar: (id: string) => `avatar:${id}`,
avatarFavorites: () => "avatar:favorites",
userGroups: (id: string) => `user:groups:${id}`,
representedGroup: (id: string) => `user:group:represented:${id}`,
};
+32
View File
@@ -0,0 +1,32 @@
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";
const path = () => join(app.getPath("userData"), "app-config.json");
type StoredConfig = Omit<AppConfig, "version">;
const DEFAULTS: StoredConfig = { gamePath: null };
function readStored(): StoredConfig {
try {
return { ...DEFAULTS, ...(JSON.parse(readFileSync(path(), "utf8")) as Partial<StoredConfig>) };
} catch {
return { ...DEFAULTS };
}
}
export function getConfig(): AppConfig {
return { version: app.getVersion(), ...readStored() };
}
export function setGamePath(gamePath: string | null): AppConfig {
const trimmed = gamePath?.trim() || null;
if (trimmed && !existsSync(trimmed)) {
throw new Error("That path doesn't exist on disk.");
}
const next: StoredConfig = { ...readStored(), gamePath: trimmed };
writeFileAtomicSync(path(), JSON.stringify(next, null, 2));
return { version: app.getVersion(), ...next };
}
+22
View File
@@ -0,0 +1,22 @@
import { userCache } from "../vrchat/userService";
import { logger, onLog } from "./logger";
import { onWsEvent } from "./wsLog";
import { broadcast } from "../windows";
export function startDebugBridge(): void {
onLog((entry) => broadcast("debug:log", entry));
onWsEvent((entry) => broadcast("ws:event", entry));
let timer: NodeJS.Timeout | null = null;
const push = (): void => {
timer = null;
broadcast("debug:cache", { cache: userCache.entries(), stats: userCache.stats() });
};
userCache.onChange((ev) => {
if (ev.type !== "hit") logger.debug("cache", ev.key ? `${ev.type} ${ev.key}` : ev.type);
if (timer) return;
timer = setTimeout(push, 120);
timer.unref?.();
});
}
+31
View File
@@ -0,0 +1,31 @@
import type { LogEntry, LogLevel } from "../../shared/types/debug";
const MAX = 500;
const buffer: LogEntry[] = [];
const listeners = new Set<(e: LogEntry) => void>();
let nextId = 1;
export function log(level: LogLevel, scope: string, message: string, data?: unknown): void {
const entry: LogEntry = { id: nextId++, ts: Date.now(), level, scope, message, data };
buffer.push(entry);
if (buffer.length > MAX) buffer.shift();
// eslint-disable-next-line no-console
console[level === "debug" ? "log" : level](`[${scope}] ${message}`, data ?? "");
for (const fn of listeners) fn(entry);
}
export const logger = {
debug: (s: string, m: string, d?: unknown) => log("debug", s, m, d),
info: (s: string, m: string, d?: unknown) => log("info", s, m, d),
warn: (s: string, m: string, d?: unknown) => log("warn", s, m, d),
error: (s: string, m: string, d?: unknown) => log("error", s, m, d),
};
export function getLogs(): LogEntry[] {
return [...buffer];
}
export function onLog(fn: (e: LogEntry) => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
+49
View File
@@ -0,0 +1,49 @@
import type { DebugSnapshot } from "../../shared/types/debug";
import type { RepoStats, StoredEntity } from "../../shared/types/repository";
import type { CacheUpdate } from "../../shared/ipc";
import { userCache } from "../vrchat/userService";
import { repos } from "../store/repository/manager";
import { getLogs } from "./logger";
import { getWsEvents } from "./wsLog";
function update(): CacheUpdate {
return { cache: userCache.entries(), stats: userCache.stats() };
}
export function snapshot(): DebugSnapshot {
return {
cache: userCache.entries(),
stats: userCache.stats(),
logs: getLogs(),
ws: getWsEvents(),
repos: repos.stats(),
};
}
export function repoStats(): RepoStats[] {
return repos.stats();
}
export function repoInspect(name: string): StoredEntity<{ id: string }>[] {
return repos.inspect(name);
}
export function repoClear(name: string): RepoStats[] {
repos.clearType(name);
return repos.stats();
}
export function repoFlush(name: string): RepoStats[] {
repos.flushType(name);
return repos.stats();
}
export function cacheInvalidate(key: string): CacheUpdate {
userCache.invalidate(key);
return update();
}
export function cacheClear(): CacheUpdate {
userCache.clear();
return update();
}
+22
View File
@@ -0,0 +1,22 @@
import type { WsEvent } from "../../shared/types/debug";
const MAX = 300;
const buffer: WsEvent[] = [];
const listeners = new Set<(e: WsEvent) => void>();
let nextId = 1;
export function recordWsEvent(type: string, content: unknown, handled: boolean): void {
const entry: WsEvent = { id: nextId++, ts: Date.now(), type, handled, content };
buffer.push(entry);
if (buffer.length > MAX) buffer.shift();
for (const fn of listeners) fn(entry);
}
export function getWsEvents(): WsEvent[] {
return [...buffer];
}
export function onWsEvent(fn: (e: WsEvent) => void): () => void {
listeners.add(fn);
return () => listeners.delete(fn);
}
+112
View File
@@ -0,0 +1,112 @@
import { homedir } from "node:os";
import { join } from "node:path";
import {
existsSync,
lstatSync,
mkdirSync,
readdirSync,
readlinkSync,
renameSync,
rmSync,
symlinkSync,
} from "node:fs";
import { vrchatPrefix } from "../game/steam";
import type { EnhancementDetail } from "../../shared/types/enhancements";
const PREFIX_TAIL = join("pfx", "drive_c", "users", "steamuser", "Pictures", "VRChat");
export const screenshotTarget = () => join(homedir(), "Pictures", "VRChat");
export function detectProtonScreenshots(): string | null {
const prefix = vrchatPrefix();
return prefix ? join(prefix, PREFIX_TAIL) : null;
}
function isSymlinkTo(path: string, target: string): boolean {
try {
return lstatSync(path).isSymbolicLink() && readlinkSync(path) === target;
} catch {
return false;
}
}
export interface SymlinkStatus {
source: string | null;
active: boolean;
detail: EnhancementDetail;
}
export function status(): SymlinkStatus {
const source = detectProtonScreenshots();
if (!source) return { source: null, active: false, detail: { key: "noPrefix" } };
const target = screenshotTarget();
return isSymlinkTo(source, target)
? { source, active: true, detail: { key: "linked", path: target } }
: { source, active: false, detail: { key: "protonFolder", path: source } };
}
function mergeInto(from: string, to: string): void {
mkdirSync(to, { recursive: true });
for (const name of readdirSync(from)) {
const src = join(from, name);
const dest = join(to, name);
if (lstatSync(src).isDirectory()) {
if (existsSync(dest) && lstatSync(dest).isDirectory()) {
mergeInto(src, dest);
} else if (existsSync(dest)) {
renameSync(src, freeName(to, name));
} else {
renameSync(src, dest);
}
continue;
}
renameSync(src, existsSync(dest) ? freeName(to, name) : dest);
}
}
function freeName(dir: string, name: string): string {
const dot = name.lastIndexOf(".");
const base = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : "";
let n = 1;
let dest: string;
do {
dest = join(dir, `${base} (${n})${ext}`);
n++;
} while (existsSync(dest));
return dest;
}
export function enable(): SymlinkStatus {
const source = detectProtonScreenshots();
if (!source) throw new Error("No VRChat Proton prefix found. Launch VRChat once, then retry.");
const target = screenshotTarget();
mkdirSync(target, { recursive: true });
if (isSymlinkTo(source, target)) return status();
if (existsSync(source)) {
const st = lstatSync(source);
if (st.isSymbolicLink()) {
rmSync(source, { force: true });
} else if (st.isDirectory()) {
mergeInto(source, target);
rmSync(source, { recursive: true, force: true });
} else {
throw new Error(`${source} exists and isn't a folder; refusing to replace it.`);
}
}
symlinkSync(target, source, "dir");
return status();
}
export function disable(): SymlinkStatus {
const source = detectProtonScreenshots();
if (source && existsSync(source) && lstatSync(source).isSymbolicLink()) {
rmSync(source, { force: true });
}
return status();
}
+69
View File
@@ -0,0 +1,69 @@
import { app } from "electron";
import { join } from "node:path";
import { readFileSync } from "node:fs";
import { writeFileAtomicSync } from "../lib/atomicFile";
import { logger } from "../debug/logger";
import type {
EnhancementId,
EnhancementState,
EnhancementsSnapshot,
OsPlatform,
} from "../../shared/types/enhancements";
import * as screenshot from "./screenshotSymlink";
const platform = () => process.platform as OsPlatform;
const storePath = () => join(app.getPath("userData"), "enhancements.json");
type Prefs = Partial<Record<EnhancementId, boolean>>;
function readPrefs(): Prefs {
try {
return JSON.parse(readFileSync(storePath(), "utf8")) as Prefs;
} catch {
return {};
}
}
function writePrefs(prefs: Prefs): void {
writeFileAtomicSync(storePath(), JSON.stringify(prefs, null, 2));
}
function screenshotState(): EnhancementState {
const s = screenshot.status();
return {
id: "linux-screenshot-symlink",
enabled: s.active,
detail: s.detail,
resolvedPath: s.source ?? undefined,
};
}
export function snapshot(): EnhancementsSnapshot {
return { platform: platform(), states: [screenshotState()] };
}
export function setEnabled(id: EnhancementId, enabled: boolean): EnhancementsSnapshot {
if (id === "linux-screenshot-symlink") {
if (platform() !== "linux") throw new Error("This enhancement only applies to Linux.");
const status = enabled ? screenshot.enable() : screenshot.disable();
const prefs = readPrefs();
prefs[id] = enabled;
writePrefs(prefs);
logger.info("enhancements", `screenshot symlink ${enabled ? "enabled" : "disabled"}`, status);
}
return snapshot();
}
export function reconcile(): void {
if (platform() !== "linux") return;
const prefs = readPrefs();
if (!prefs["linux-screenshot-symlink"]) return;
try {
if (!screenshot.status().active) {
screenshot.enable();
logger.info("enhancements", "re-applied screenshot symlink on startup");
}
} catch (err) {
logger.warn("enhancements", "could not re-apply screenshot symlink", err);
}
}
+69
View File
@@ -0,0 +1,69 @@
import { open } from "node:fs/promises";
import type { PhotoMetadata } from "../../shared/types/gallery";
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const HEAD_BYTES = 64 * 1024;
function tag(xml: string, name: string): string | undefined {
const m = xml.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`));
return m ? decodeEntities(m[1].trim()) : undefined;
}
function decodeEntities(s: string): string {
return s
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'");
}
function parseXmp(xml: string): PhotoMetadata {
const created = tag(xml, "xmp:CreateDate");
return {
author: tag(xml, "xmp:Author"),
authorId: tag(xml, "vrc:AuthorID"),
worldId: tag(xml, "vrc:WorldID"),
worldName: tag(xml, "vrc:WorldDisplayName"),
takenAt: created ? new Date(created).toISOString() : undefined,
};
}
export async function readPngMetadata(path: string): Promise<PhotoMetadata> {
const fh = await open(path, "r");
try {
const buf = Buffer.alloc(HEAD_BYTES);
const { bytesRead } = await fh.read(buf, 0, HEAD_BYTES, 0);
const head = buf.subarray(0, bytesRead);
if (!head.subarray(0, 8).equals(PNG_SIG)) return {};
let meta: PhotoMetadata = {};
let off = 8;
while (off + 8 <= head.length) {
const len = head.readUInt32BE(off);
const type = head.toString("latin1", off + 4, off + 8);
const dataStart = off + 8;
if (type === "IHDR" && dataStart + 8 <= head.length) {
meta.width = head.readUInt32BE(dataStart);
meta.height = head.readUInt32BE(dataStart + 4);
} else if (type === "iTXt") {
const data = head.subarray(dataStart, dataStart + len);
const text = data.toString("utf8");
if (text.includes("x:xmpmeta")) {
const xml = text.slice(text.indexOf("<x:xmpmeta"));
meta = { ...meta, ...parseXmp(xml) };
}
}
if (type === "IDAT" || type === "IEND") break;
off = dataStart + len + 4;
}
return meta;
} catch {
return {};
} finally {
await fh.close();
}
}
+36
View File
@@ -0,0 +1,36 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { existsSync, lstatSync, realpathSync } from "node:fs";
import { detectProtonScreenshots } from "../enhancements/screenshotSymlink";
function picturesVRChat(): string {
return join(homedir(), "Pictures", "VRChat");
}
function isRealDir(path: string): boolean {
try {
return lstatSync(path).isDirectory();
} catch {
return false;
}
}
export function galleryRoots(): string[] {
const candidates = [picturesVRChat()];
const proton = detectProtonScreenshots();
if (proton) candidates.push(proton);
const seen = new Set<string>();
const roots: string[] = [];
for (const path of candidates) {
if (!existsSync(path) || !isRealDir(path)) continue;
let real = path;
try {
real = realpathSync(path);
} catch {}
if (seen.has(real)) continue;
seen.add(real);
roots.push(path);
}
return roots;
}
+69
View File
@@ -0,0 +1,69 @@
import { protocol, net } from "electron";
import { pathToFileURL } from "node:url";
import { sep } from "node:path";
import { realpathSync } from "node:fs";
import { galleryRoots } from "./paths";
import { getThumbnail } from "./thumbnails";
import { logger } from "../debug/logger";
export const GALLERY_SCHEME = "vrcgallery";
export function registerGalleryScheme(): void {
protocol.registerSchemesAsPrivileged([
{
scheme: GALLERY_SCHEME,
privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true },
},
]);
}
export function photoUrl(absPath: string): string {
return `${GALLERY_SCHEME}://photo/${encodeURIComponent(absPath)}`;
}
export function thumbUrl(absPath: string): string {
return `${GALLERY_SCHEME}://thumb/${encodeURIComponent(absPath)}`;
}
function isUnderRoot(target: string): boolean {
let real: string;
try {
real = realpathSync(target);
} catch {
return false;
}
return galleryRoots().some((root) => {
let rootReal: string;
try {
rootReal = realpathSync(root);
} catch {
return false;
}
return real === rootReal || real.startsWith(rootReal + sep);
});
}
export function registerGalleryProtocol(): void {
protocol.handle(GALLERY_SCHEME, async (request) => {
const url = new URL(request.url);
const kind = url.host;
const encoded = url.pathname.replace(/^\/+/, "");
const filePath = decodeURIComponent(encoded);
if (!isUnderRoot(filePath)) {
logger.warn("gallery", "blocked out-of-root file request", { filePath });
return new Response("Forbidden", { status: 403 });
}
if (kind === "thumb") {
const jpeg = await getThumbnail(filePath);
if (jpeg) {
return new Response(new Uint8Array(jpeg), {
headers: { "content-type": "image/jpeg", "cache-control": "max-age=31536000" },
});
}
}
return net.fetch(pathToFileURL(filePath).toString());
});
}
+103
View File
@@ -0,0 +1,103 @@
import { join, basename, relative, dirname, sep } from "node:path";
import { readdir, stat } from "node:fs/promises";
import { shell } from "electron";
import { galleryRoots } from "./paths";
import { photoUrl, thumbUrl } from "./protocol";
import { readPngMetadata } from "./metadata";
import { logger } from "../debug/logger";
import type { GallerySnapshot, Photo } from "../../shared/types/gallery";
const IMAGE_EXT = /\.(png|jpe?g)$/i;
type CacheEntry = { key: string; photo: Photo };
const photoCache = new Map<string, CacheEntry>();
async function walk(dir: string, out: string[]): Promise<void> {
let entries: import("node:fs").Dirent[];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) {
await walk(full, out);
} else if (e.isFile() && IMAGE_EXT.test(e.name)) {
out.push(full);
}
}
}
function bucketOf(root: string, path: string): string {
const rel = relative(root, path);
const head = rel.split(sep)[0];
return head && head !== basename(path) ? head : basename(dirname(path));
}
async function toPhoto(root: string, path: string): Promise<Photo | null> {
let st: import("node:fs").Stats;
try {
st = await stat(path);
} catch {
return null;
}
const key = `${path}:${st.mtimeMs}:${st.size}`;
const cached = photoCache.get(path);
if (cached && cached.key === key) return cached.photo;
const metadata = /\.png$/i.test(path) ? await readPngMetadata(path) : {};
const photo: Photo = {
id: path,
fileName: basename(path),
bucket: bucketOf(root, path),
src: photoUrl(path),
thumb: thumbUrl(path),
sizeBytes: st.size,
modifiedAt: new Date(st.mtimeMs).toISOString(),
metadata,
};
photoCache.set(path, { key, photo });
return photo;
}
export async function photoAt(path: string): Promise<Photo | null> {
const root = galleryRoots().find((r) => path === r || path.startsWith(r + sep)) ?? dirname(path);
return toPhoto(root, path);
}
export async function snapshot(): Promise<GallerySnapshot> {
const roots = galleryRoots();
if (roots.length === 0) return { roots: [], empty: true, photos: [] };
const files: { root: string; path: string }[] = [];
for (const root of roots) {
const found: string[] = [];
await walk(root, found);
for (const path of found) files.push({ root, path });
}
const photos = (await Promise.all(files.map(({ root, path }) => toPhoto(root, path)))).filter(
(p): p is Photo => p !== null,
);
photos.sort((a, b) => sortKey(b).localeCompare(sortKey(a)));
logger.info("gallery", `scanned ${photos.length} photos across ${roots.length} root(s)`);
return { roots, empty: false, photos };
}
function sortKey(p: Photo): string {
return p.metadata.takenAt ?? p.modifiedAt;
}
export async function remove(paths: string[]): Promise<void> {
const results = await Promise.allSettled(
paths.map(async (path) => {
await shell.trashItem(path);
photoCache.delete(path);
}),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) throw new Error(`Couldn't delete ${failed} of ${paths.length} photo(s).`);
}
+102
View File
@@ -0,0 +1,102 @@
import { app } from "electron";
import sharp from "sharp";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { mkdirSync } from "node:fs";
import { readFile, writeFile, stat, readdir, rm } from "node:fs/promises";
import { logger } from "../debug/logger";
import type { ThumbCacheStats } from "../../shared/types/gallery";
const THUMB_EDGE = 480;
const THUMB_QUALITY = 72;
sharp.concurrency(2);
let dirReady = false;
function thumbDir(): string {
const dir = join(app.getPath("userData"), "thumbnails");
if (!dirReady) {
mkdirSync(dir, { recursive: true });
dirReady = true;
}
return dir;
}
function cachePath(srcPath: string, mtimeMs: number, size: number): string {
const hash = createHash("sha1").update(`${srcPath}:${mtimeMs}:${size}`).digest("hex");
return join(thumbDir(), `${hash}.jpg`);
}
const pending = new Map<string, Promise<Buffer | null>>();
export async function getThumbnail(srcPath: string): Promise<Buffer | null> {
let st: Awaited<ReturnType<typeof stat>>;
try {
st = await stat(srcPath);
} catch {
return null;
}
const dest = cachePath(srcPath, st.mtimeMs, st.size);
try {
return await readFile(dest);
} catch {}
const existing = pending.get(dest);
if (existing) return existing;
const job = build(srcPath, dest);
pending.set(dest, job);
try {
return await job;
} finally {
pending.delete(dest);
}
}
export async function thumbStats(): Promise<ThumbCacheStats> {
const dir = thumbDir();
let count = 0;
let totalBytes = 0;
try {
const names = await readdir(dir);
for (const name of names) {
if (!name.endsWith(".jpg")) continue;
try {
const st = await stat(join(dir, name));
count++;
totalBytes += st.size;
} catch {}
}
} catch {}
return { count, totalBytes, dir };
}
export async function clearThumbnails(): Promise<ThumbCacheStats> {
const dir = thumbDir();
try {
const names = await readdir(dir);
await Promise.all(
names
.filter((n) => n.endsWith(".jpg"))
.map((n) => rm(join(dir, n), { force: true }).catch(() => {})),
);
logger.info("gallery", `cleared ${names.length} thumbnail(s)`);
} catch {}
return thumbStats();
}
async function build(srcPath: string, dest: string): Promise<Buffer | null> {
try {
const jpeg = await sharp(srcPath, { failOn: "none", limitInputPixels: false })
.rotate()
.resize(THUMB_EDGE, THUMB_EDGE, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: THUMB_QUALITY, mozjpeg: true })
.toBuffer();
await writeFile(dest, jpeg).catch(() => {});
return jpeg;
} catch (err) {
logger.warn("gallery", "thumbnail generation failed", { srcPath, err: String(err) });
return null;
}
}
+95
View File
@@ -0,0 +1,95 @@
import { watch, type FSWatcher } from "node:fs";
import { readdir, stat } from "node:fs/promises";
import { join } from "node:path";
import { galleryRoots } from "./paths";
import { photoAt } from "./service";
import { broadcast } from "../windows";
import { logger } from "../debug/logger";
const IMAGE_EXT = /\.(png|jpe?g)$/i;
const watchers = new Map<string, FSWatcher>();
const pending = new Set<string>();
const emitted = new Set<string>();
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
// VRChat writes screenshots in chunks
async function settle(path: string): Promise<boolean> {
let last = -1;
for (let i = 0; i < 40; i++) {
let s;
try {
s = await stat(path);
} catch {
return false;
}
if (!s.isFile()) return false;
if (s.size > 0 && s.size === last) return true;
last = s.size;
await delay(150);
}
return true;
}
async function onCandidate(path: string): Promise<void> {
if (!IMAGE_EXT.test(path) || pending.has(path) || emitted.has(path)) return;
pending.add(path);
const ok = await settle(path);
pending.delete(path);
if (!ok) return;
emitted.add(path);
const photo = await photoAt(path);
if (photo) {
broadcast("gallery:added", photo);
logger.info("gallery", `new photo ${photo.fileName}`);
}
}
async function handleEntry(full: string): Promise<void> {
let s;
try {
s = await stat(full);
} catch {
return;
}
if (s.isDirectory()) watchDir(full);
else if (s.isFile()) void onCandidate(full);
}
function watchDir(dir: string): void {
if (watchers.has(dir)) return;
let w: FSWatcher;
try {
w = watch(dir, (_event, filename) => {
if (filename) void handleEntry(join(dir, filename.toString()));
});
} catch {
return;
}
w.on("error", () => {
w.close();
watchers.delete(dir);
});
watchers.set(dir, w);
}
export function startGalleryWatch(): void {
const roots = galleryRoots();
for (const root of roots) {
watchDir(root);
readdir(root, { withFileTypes: true })
.then((entries) => {
for (const e of entries) if (e.isDirectory()) watchDir(join(root, e.name));
})
.catch(() => {});
}
logger.info("gallery", `watching ${roots.length} root(s) for new photos`);
}
export function stopGalleryWatch(): void {
for (const w of watchers.values()) w.close();
watchers.clear();
pending.clear();
emitted.clear();
}
+62
View File
@@ -0,0 +1,62 @@
import { shell } from "electron";
import { exec } from "node:child_process";
import { promisify } from "node:util";
import { VRCHAT_APPID } from "./steam";
import type { GameStatus } from "../../shared/types/game";
import { broadcast } from "../windows";
const sh = promisify(exec);
const SUPPORTED = process.platform !== "darwin";
async function isRunning(): Promise<boolean> {
try {
if (process.platform === "win32") {
const { stdout } = await sh('tasklist /fi "imagename eq VRChat.exe" /nh');
return /vrchat\.exe/i.test(stdout);
}
const { stdout } = await sh("ps -A -o args=");
return stdout.split("\n").some((line) => /vrchat\.exe/i.test(line) && !/grep/i.test(line));
} catch {
return false;
}
}
export async function status(): Promise<GameStatus> {
if (!SUPPORTED) return { running: false, supported: false };
return setRunning(await isRunning());
}
async function focus(): Promise<void> {
if (process.platform !== "linux") return;
try {
await sh(`xdotool search --class steam_app_${VRCHAT_APPID} windowactivate %@`);
} catch {}
}
export async function launch(): Promise<GameStatus> {
if (!SUPPORTED) return { running: false, supported: false };
if (await isRunning()) {
await focus();
return setRunning(true);
}
await shell.openExternal(`steam://rungameid/${VRCHAT_APPID}`);
return { running: lastRunning, supported: true };
}
let lastRunning = false;
function setRunning(running: boolean): GameStatus {
if (running !== lastRunning) {
lastRunning = running;
broadcast("game:changed", { running, supported: true });
}
return { running, supported: true };
}
export function startWatcher(): void {
if (!SUPPORTED) return;
const tick = () => void isRunning().then(setRunning);
tick();
setInterval(tick, 5000);
}
+62
View File
@@ -0,0 +1,62 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { existsSync, readFileSync } from "node:fs";
import { getConfig } from "../config/appConfig";
export const VRCHAT_APPID = "438100";
function platformBases(): string[] {
const home = homedir();
switch (process.platform) {
case "win32":
return [
join(process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)", "Steam"),
join(process.env.ProgramFiles ?? "C:\\Program Files", "Steam"),
];
case "darwin":
return [join(home, "Library", "Application Support", "Steam")];
default:
return [
join(home, ".steam", "steam"),
join(home, ".steam", "root"),
join(home, ".local", "share", "Steam"),
join(home, ".var", "app", "com.valvesoftware.Steam", ".local", "share", "Steam"),
];
}
}
export function steamLibraries(): string[] {
const bases = platformBases();
const libs = new Set<string>();
const override = getConfig().gamePath;
if (override) {
if (existsSync(join(override, "steamapps"))) libs.add(join(override, "steamapps"));
if (existsSync(join(override, "compatdata"))) libs.add(override);
bases.unshift(override);
}
for (const base of bases) {
const lib = join(base, "steamapps");
if (existsSync(lib)) libs.add(lib);
const vdf = join(lib, "libraryfolders.vdf");
if (!existsSync(vdf)) continue;
try {
for (const m of readFileSync(vdf, "utf8").matchAll(/"path"\s*"([^"]+)"/g)) {
const other = join(m[1].replace(/\\\\/g, "\\"), "steamapps");
if (existsSync(other)) libs.add(other);
}
} catch {
/* malformed vdf */
}
}
return [...libs];
}
export function vrchatPrefix(): string | null {
for (const lib of steamLibraries()) {
const prefix = join(lib, "compatdata", VRCHAT_APPID);
if (existsSync(prefix)) return prefix;
}
return null;
}
+71
View File
@@ -0,0 +1,71 @@
import { app, BrowserWindow } from "electron";
import { join } from "node:path";
import { registerIpcHandlers } from "./ipc/handlers";
import { reconcile as reconcileEnhancements } from "./enhancements/service";
import { registerGalleryScheme, registerGalleryProtocol } from "./gallery/protocol";
import { startSocialBridge } from "./store/social";
import { startWatcher as startGameWatcher } from "./game/launch";
import { startGalleryWatch, stopGalleryWatch } from "./gallery/watcher";
import { startDebugBridge } from "./debug/bridge";
import { logger } from "./debug/logger";
import { userCache } from "./vrchat/userService";
import { repos } from "./store/repository/manager";
import { activeId } from "./accounts/store";
import { closeClients } from "./vrchat/client";
import { createMainWindow, focusMainWindow } from "./windows";
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on("second-instance", () => focusMainWindow());
registerGalleryScheme();
start();
}
function start(): void {
let shuttingDown = false;
const shutdown = (): void => {
if (shuttingDown) return;
shuttingDown = true;
stopGalleryWatch();
userCache.flushNow();
repos.flushAll();
closeClients();
};
const exitFromSignal = (): void => {
shutdown();
app.exit(0);
};
process.once("SIGINT", exitFromSignal);
process.once("SIGTERM", exitFromSignal);
app.whenReady().then(() => {
userCache.persistTo(join(app.getPath("userData"), "cache.json"));
repos.setActive(activeId());
logger.info("app", `VRC Circle ${app.getVersion()} ready`);
registerGalleryProtocol();
registerIpcHandlers();
reconcileEnhancements();
startSocialBridge();
startDebugBridge();
startGameWatcher();
startGalleryWatch();
createMainWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createMainWindow();
});
});
app.on("before-quit", () => {
shutdown();
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") app.quit();
});
}
+127
View File
@@ -0,0 +1,127 @@
import { BrowserWindow, dialog, ipcMain, shell } from "electron";
import type { IpcRequestChannel, IpcRequests } from "../../shared/ipc";
import { guard } from "../vrchat/errors";
import * as auth from "../vrchat/authService";
import * as users from "../vrchat/userService";
import * as friends from "../vrchat/friendsService";
import * as worlds from "../vrchat/worldService";
import * as avatars from "../vrchat/avatarService";
import * as groups from "../vrchat/groupService";
import * as settings from "../vrchat/settingsService";
import * as debug from "../debug/service";
import * as enhancements from "../enhancements/service";
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 { socialSnapshot } from "../store/social";
import { worldStore } from "../store/worldStore";
import { openDebugWindow } from "../windows";
const handlers = {
"auth:status": () => guard(() => auth.checkStatus()),
"auth:login": (creds) => guard(() => auth.login(creds)),
"auth:verify2fa": (payload) => guard(() => auth.verify2fa(payload)),
"auth:logout": () => guard(() => auth.logout()),
"accounts:list": () => guard(async () => auth.listAccountsState()),
"accounts:switch": (id) => guard(() => auth.switchAccount(id)),
"accounts:remove": (id) => guard(async () => auth.removeAccountAction(id)),
"user:me": () => guard(() => users.currentUser()),
"user:get": (userId) => guard(() => users.getUser(userId)),
"user:getByName": (username) => guard(() => users.getUserByName(username)),
"user:search": (query) => guard(() => users.searchUsers(query)),
"friends:list": () => guard(() => friends.listFriends()),
"world:byUser": (userId) =>
guard(async () => {
const me = await users.currentUser();
return worlds.getUserWorlds(userId, userId === me.id);
}),
"world:favorites": (userId) => guard(() => worlds.getFavoriteWorlds(userId)),
"world:search": (query) => guard(() => worlds.searchWorlds(query)),
"world:get": (worldId) => guard(() => worlds.getWorld(worldId)),
"world:snapshot": () => guard(async () => worldStore.snapshot()),
"avatar:get": (avatarId) => guard(() => avatars.getAvatar(avatarId)),
"avatar:favorites": () => guard(() => avatars.getFavoritedAvatars()),
"group:byUser": (userId) => guard(() => groups.getUserGroups(userId)),
"group:represented": (userId) => guard(() => groups.getRepresentedGroup(userId)),
"social:snapshot": () => guard(async () => socialSnapshot()),
"settings:get": () => guard(() => settings.getSettings()),
"settings:displayName": (p) =>
guard(() => settings.setDisplayName(p.displayName, p.currentPassword)),
"settings:revertDisplayName": (p) => guard(() => settings.revertDisplayName(p.currentPassword)),
"settings:email": (p) => guard(() => settings.setEmail(p.email, p.currentPassword)),
"settings:password": (p) => guard(() => settings.setPassword(p.currentPassword, p.newPassword)),
"settings:privacy": (p) => guard(() => settings.setPrivacy(p)),
"settings:status": (p) => guard(() => settings.setPresence(p.status, p.statusDescription)),
"settings:contentFilters": (filters) => guard(() => settings.setContentFilters(filters)),
"settings:enable2fa": () => guard(() => settings.beginTwoFactorSetup()),
"settings:verify2fa": (code) => guard(() => settings.verifyTwoFactorSetup(code)),
"settings:disable2fa": () => guard(() => settings.disableTwoFactor()),
"settings:recoveryCodes": () => guard(() => settings.getRecoveryCodes()),
"settings:reverify2fa": (p) => guard(() => settings.reverify2fa(p.method, p.code)),
"settings:resetUserData": () => guard(() => settings.resetUserData()),
"settings:deleteAccount": () => guard(() => settings.deleteAccount()),
"config:get": () => guard(async () => appConfig.getConfig()),
"config:setGamePath": (p) => guard(async () => appConfig.setGamePath(p.gamePath)),
"config:pickGamePath": () =>
guard(async () => {
const win = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0];
const res = await dialog.showOpenDialog(win, {
title: "Select your Steam or VRChat folder",
properties: ["openDirectory"],
});
if (res.canceled || !res.filePaths[0]) return appConfig.getConfig();
return appConfig.setGamePath(res.filePaths[0]);
}),
"game:status": () => guard(() => game.status()),
"game:launch": () => guard(() => game.launch()),
"gallery:snapshot": () => guard(async () => gallery.snapshot()),
"gallery:reveal": (path) => guard(async () => void shell.showItemInFolder(path)),
"gallery:openExternal": (path) =>
guard(async () => {
await shell.openPath(path);
}),
"gallery:delete": (paths) => guard(async () => gallery.remove(paths)),
"gallery:thumbStats": () => guard(() => thumbStats()),
"gallery:thumbClear": () => guard(() => clearThumbnails()),
"enhancements:snapshot": () => guard(async () => enhancements.snapshot()),
"enhancements:setEnabled": (p) => guard(async () => enhancements.setEnabled(p.id, p.enabled)),
"debug:snapshot": () => guard(async () => debug.snapshot()),
"debug:cacheInvalidate": (key) => guard(async () => debug.cacheInvalidate(key)),
"debug:cacheClear": () => guard(async () => debug.cacheClear()),
"debug:repoStats": () => guard(async () => debug.repoStats()),
"debug:repoInspect": (name) => guard(async () => debug.repoInspect(name)),
"debug:repoClear": (name) => guard(async () => debug.repoClear(name)),
"debug:repoFlush": (name) => guard(async () => debug.repoFlush(name)),
"debug:openWindow": () => guard(async () => openDebugWindow()),
} satisfies {
[C in IpcRequestChannel]: (...args: Parameters<IpcRequests[C]>) => Promise<ReturnType<IpcRequests[C]>>;
};
export function registerIpcHandlers(): void {
for (const channel of Object.keys(handlers) as IpcRequestChannel[]) {
register(channel);
}
}
function register<C extends IpcRequestChannel>(channel: C): void {
const handler = handlers[channel] as (
...args: Parameters<IpcRequests[C]>
) => Promise<ReturnType<IpcRequests[C]>>;
ipcMain.handle(channel, (_event, ...args) =>
handler(...(args as Parameters<IpcRequests[C]>)),
);
}
+33
View File
@@ -0,0 +1,33 @@
import { mkdir, open, rename } from "node:fs/promises";
import { mkdirSync, renameSync, writeFileSync, openSync, fsyncSync, closeSync } from "node:fs";
import { dirname } from "node:path";
function tmpName(file: string): string {
return `${file}.${process.pid}.${Date.now()}.tmp`;
}
export async function writeFileAtomic(file: string, data: string): Promise<void> {
await mkdir(dirname(file), { recursive: true });
const tmp = tmpName(file);
const handle = await open(tmp, "w");
try {
await handle.writeFile(data);
await handle.sync();
} finally {
await handle.close();
}
await rename(tmp, file);
}
export function writeFileAtomicSync(file: string, data: string): void {
mkdirSync(dirname(file), { recursive: true });
const tmp = tmpName(file);
const fd = openSync(tmp, "w");
try {
writeFileSync(fd, data);
fsyncSync(fd);
} finally {
closeSync(fd);
}
renameSync(tmp, file);
}
+88
View File
@@ -0,0 +1,88 @@
import type { SocialSnapshot, UserProfile } from "../../shared/types/user";
import type { FieldSource } from "../../shared/types/repository";
import { repos } from "./repository/manager";
export type { SocialSnapshot };
type Change = { type: "seed"; snapshot: SocialSnapshot } | { type: "upsert"; user: UserProfile };
type Listener = (change: Change) => void;
class EntityStore {
private readonly listeners = new Set<Listener>();
private selfId: string | null = null;
private wired = false;
onChange(fn: Listener): () => void {
this.wire();
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private wire(): void {
if (this.wired || !repos.hasActive) return;
this.wired = true;
repos.active.users.onChange((c) => {
this.emit({ type: "upsert", user: c.entity });
});
}
seed(self: UserProfile, friends: UserProfile[]): void {
this.wired = false;
this.wire();
this.selfId = self.id;
const users = repos.active.users;
users.upsert(self, "rest:detail");
for (const f of friends) users.upsert({ ...f, isFriend: true }, "rest:list");
this.emit({ type: "seed", snapshot: this.snapshot() });
}
upsert(partial: Partial<UserProfile> & { id: string }, at?: number): void {
this.upsertFrom(partial, "ws", at);
}
upsertFrom(partial: Partial<UserProfile> & { id: string }, src: FieldSource, at?: number): void {
if (!repos.hasActive) return;
repos.active.users.upsert(partial, src, at);
}
addFriend(user: UserProfile): void {
repos.active.users.upsert({ ...user, isFriend: true }, "rest:detail");
}
removeFriend(id: string): void {
repos.active.users.upsert({ id, isFriend: false }, "rest:detail");
}
get(id: string): UserProfile | undefined {
return repos.hasActive ? repos.active.users.get(id) : undefined;
}
friends(): UserProfile[] {
return repos.hasActive ? repos.active.users.filter((u) => u.isFriend) : [];
}
snapshot(): SocialSnapshot {
return {
selfId: this.selfId,
users: repos.hasActive ? repos.active.users.all() : [],
};
}
reset(): void {
this.selfId = null;
this.wired = false;
this.wire();
this.emit({ type: "seed", snapshot: this.snapshot() });
}
clear(): void {
this.selfId = null;
this.emit({ type: "seed", snapshot: this.snapshot() });
}
private emit(change: Change): void {
for (const fn of this.listeners) fn(change);
}
}
export const entityStore = new EntityStore();
+113
View File
@@ -0,0 +1,113 @@
import { existsSync, readFileSync, appendFileSync, rmSync, statSync } from "node:fs";
import { dirname } from "node:path";
import { mkdirSync } from "node:fs";
import { writeFileAtomicSync } from "../../lib/atomicFile";
import type { StoredEntity } from "../../../shared/types/repository";
export interface StorageBackend<T> {
load(): Map<string, StoredEntity<T>>;
put(id: string, entity: StoredEntity<T>): void;
remove(id: string): void;
flush(map: Map<string, StoredEntity<T>>): void;
clear(): void;
file: string | null;
}
type LogLine<T> = { op: "put"; id: string; e: StoredEntity<T> } | { op: "del"; id: string };
export class JsonlBackend<T> implements StorageBackend<T> {
private readonly base: string;
private readonly log: string;
private appendCount = 0;
constructor(file: string) {
this.base = `${file}.json`;
this.log = `${file}.log`;
mkdirSync(dirname(file), { recursive: true });
}
get file(): string {
return this.base;
}
load(): Map<string, StoredEntity<T>> {
const map = new Map<string, StoredEntity<T>>();
if (existsSync(this.base)) {
try {
const raw = JSON.parse(readFileSync(this.base, "utf8")) as Record<string, StoredEntity<T>>;
for (const [id, e] of Object.entries(raw)) map.set(id, e);
} catch {
map.clear();
}
}
if (existsSync(this.log)) {
const text = readFileSync(this.log, "utf8");
for (const line of text.split("\n")) {
if (!line) continue;
try {
const entry = JSON.parse(line) as LogLine<T>;
if (entry.op === "put") map.set(entry.id, entry.e);
else map.delete(entry.id);
} catch {
continue;
}
}
}
this.appendCount = 0;
if (this.logIsLarge()) this.compact(map);
return map;
}
put(id: string, entity: StoredEntity<T>): void {
this.appendLine({ op: "put", id, e: entity });
}
remove(id: string): void {
this.appendLine({ op: "del", id });
}
private appendLine(entry: LogLine<T>): void {
try {
appendFileSync(this.log, JSON.stringify(entry) + "\n");
this.appendCount++;
} catch {
return;
}
}
private logIsLarge(): boolean {
try {
if (!existsSync(this.log)) return false;
const logBytes = statSync(this.log).size;
const baseBytes = existsSync(this.base) ? statSync(this.base).size : 0;
return logBytes > 256 * 1024 && logBytes >= baseBytes;
} catch {
return false;
}
}
flush(map: Map<string, StoredEntity<T>>): void {
if (this.appendCount === 0) return;
this.compact(map);
}
clear(): void {
rmSync(this.base, { force: true });
rmSync(this.log, { force: true });
this.appendCount = 0;
}
private compact(map: Map<string, StoredEntity<T>>): void {
try {
writeFileAtomicSync(this.base, JSON.stringify(Object.fromEntries(map)));
rmSync(this.log, { force: true });
this.appendCount = 0;
} catch {
return;
}
}
destroy(): void {
this.clear();
}
}
+84
View File
@@ -0,0 +1,84 @@
import type { FieldSource } from "../../../shared/types/repository";
export type FieldClass = "identity" | "stat" | "live";
export interface FieldPolicy<T> {
classOf: (field: keyof T & string) => FieldClass;
maxAge: Record<FieldClass, number>;
keepNonEmpty?: ReadonlySet<keyof T & string>;
}
const SOURCE_PRIORITY: Record<FieldSource, number> = {
seed: 0,
"rest:search": 1,
"rest:list": 2,
"rest:detail": 3,
ws: 4,
};
export function sourcePriority(src: FieldSource): number {
return SOURCE_PRIORITY[src] ?? 0;
}
const MIN = 60_000;
const HOUR = 60 * MIN;
const DAY = 24 * HOUR;
function table<T>(
classes: Partial<Record<keyof T & string, FieldClass>>,
maxAge: Record<FieldClass, number>,
keepNonEmpty?: ReadonlySet<keyof T & string>,
): FieldPolicy<T> {
return {
classOf: (f) => classes[f] ?? "identity",
maxAge,
keepNonEmpty,
};
}
export const worldFieldPolicy = table<import("../../../shared/types/world").World>(
{
occupants: "live",
publicOccupants: "live",
privateOccupants: "live",
heat: "live",
favorites: "stat",
visits: "stat",
popularity: "stat",
},
{ identity: 30 * DAY, stat: 6 * HOUR, live: 2 * MIN },
);
export const WS_STRING_FIELDS = [
"displayName",
"bio",
"userIcon",
"profilePicOverride",
"currentAvatarThumbnailImageUrl",
"location",
] as const satisfies readonly (keyof import("../../../shared/types/user").UserProfile)[];
export const userFieldPolicy = table<import("../../../shared/types/user").UserProfile>(
{
status: "live",
statusDescription: "live",
state: "live",
location: "live",
displayName: "live",
bio: "live",
userIcon: "live",
profilePicOverride: "live",
currentAvatarThumbnailImageUrl: "live",
tags: "live",
trustRank: "live",
},
{ identity: 30 * DAY, stat: 6 * HOUR, live: 1 * MIN },
new Set(["statusDescription", "userIcon", "profilePicOverride", "bio"]),
);
export const avatarFieldPolicy = table<import("../../../shared/types/avatar").Avatar>(
{
favorites: "stat",
},
{ identity: 30 * DAY, stat: 6 * HOUR, live: 1 * MIN },
);
+128
View File
@@ -0,0 +1,128 @@
import { app } from "electron";
import { join } from "node:path";
import { rmSync } from "node:fs";
import type { World } from "../../../shared/types/world";
import type { UserProfile } from "../../../shared/types/user";
import type { Avatar } from "../../../shared/types/avatar";
import type { RepoStats, StoredEntity } from "../../../shared/types/repository";
interface InspectableRepo {
entries(): StoredEntity<{ id: string }>[];
clear(): void;
flush(): void;
}
import { Repository } from "./repository";
import { JsonlBackend } from "./backend";
import { avatarFieldPolicy, userFieldPolicy, worldFieldPolicy } from "./fieldPolicy";
export interface AccountRepos {
worlds: Repository<World>;
users: Repository<UserProfile>;
avatars: Repository<Avatar>;
}
function dbDir(): string {
return join(app.getPath("userData"), "entities");
}
function fileFor(accountId: string, type: string): string {
return join(dbDir(), `${accountId}.${type}`);
}
class RepositoryManager {
private readonly accounts = new Map<string, AccountRepos>();
private activeId: string | null = null;
private open(accountId: string): AccountRepos {
let repos = this.accounts.get(accountId);
if (repos) return repos;
repos = {
worlds: new Repository<World>({
name: "worlds",
policy: worldFieldPolicy,
backend: new JsonlBackend<World>(fileFor(accountId, "worlds")),
}),
users: new Repository<UserProfile>({
name: "users",
policy: userFieldPolicy,
backend: new JsonlBackend<UserProfile>(fileFor(accountId, "users")),
staleLiveOnLoad: true,
}),
avatars: new Repository<Avatar>({
name: "avatars",
policy: avatarFieldPolicy,
backend: new JsonlBackend<Avatar>(fileFor(accountId, "avatars")),
}),
};
this.accounts.set(accountId, repos);
return repos;
}
setActive(accountId: string | null): void {
if (accountId === this.activeId) return;
this.activeId = accountId;
if (accountId) this.open(accountId);
}
get active(): AccountRepos {
if (!this.activeId) throw new Error("No active account for repositories");
return this.open(this.activeId);
}
get hasActive(): boolean {
return this.activeId !== null;
}
flushAll(): void {
for (const repos of this.accounts.values()) {
repos.worlds.flush();
repos.users.flush();
repos.avatars.flush();
}
}
destroy(accountId: string): void {
const repos = this.accounts.get(accountId);
if (repos) {
repos.worlds.flush();
repos.users.flush();
repos.avatars.flush();
this.accounts.delete(accountId);
}
for (const type of ["worlds", "users", "avatars"]) {
const base = fileFor(accountId, type);
rmSync(`${base}.json`, { force: true });
rmSync(`${base}.log`, { force: true });
}
if (this.activeId === accountId) this.activeId = null;
}
stats(): RepoStats[] {
if (!this.activeId) return [];
const repos = this.open(this.activeId);
return [repos.worlds.stats(), repos.users.stats(), repos.avatars.stats()];
}
private repoByName(name: string): InspectableRepo | null {
if (!this.activeId) return null;
const r = this.open(this.activeId);
if (name === "worlds") return r.worlds;
if (name === "users") return r.users;
if (name === "avatars") return r.avatars;
return null;
}
inspect(name: string): StoredEntity<{ id: string }>[] {
return this.repoByName(name)?.entries() ?? [];
}
clearType(name: string): void {
this.repoByName(name)?.clear();
}
flushType(name: string): void {
this.repoByName(name)?.flush();
}
}
export const repos = new RepositoryManager();
+289
View File
@@ -0,0 +1,289 @@
import type {
EntityMeta,
FieldSource,
RepoStats,
StoredEntity,
} from "../../../shared/types/repository";
import type { StorageBackend } from "./backend";
import { type FieldPolicy, sourcePriority } from "./fieldPolicy";
export type RepoChange<T> = { type: "upsert"; id: string; entity: T };
type Listener<T> = (change: RepoChange<T>) => void;
const EVICT_INTERVAL_MS = 60_000;
interface Entity {
id: string;
}
export interface RepositoryOptions<T extends Entity> {
name: string;
policy: FieldPolicy<T>;
backend: StorageBackend<T>;
maxEntries?: number;
maxUnreadMs?: number;
staleLiveOnLoad?: boolean;
}
export class Repository<T extends Entity> {
private readonly map: Map<string, StoredEntity<T>>;
private readonly listeners = new Set<Listener<T>>();
private readonly policy: FieldPolicy<T>;
private readonly backend: StorageBackend<T>;
private readonly name: string;
private readonly maxEntries: number;
private readonly maxUnreadMs: number;
private pendingWrites = 0;
private saveTimer: NodeJS.Timeout | null = null;
private lastEvict = Date.now();
constructor(opts: RepositoryOptions<T>) {
this.name = opts.name;
this.policy = opts.policy;
this.backend = opts.backend;
this.maxEntries = opts.maxEntries ?? 50_000;
this.maxUnreadMs = opts.maxUnreadMs ?? 90 * 24 * 60 * 60_000;
this.map = this.backend.load();
if (opts.staleLiveOnLoad) this.markLiveStaleOnLoad();
}
onChange(fn: Listener<T>): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
peek(id: string): T | undefined {
return this.map.get(id)?.data;
}
get(id: string): T | undefined {
const e = this.map.get(id);
if (!e) return undefined;
e.meta.lastRead = Date.now();
return e.data;
}
has(id: string): boolean {
return this.map.has(id);
}
all(): T[] {
return [...this.map.values()].map((e) => e.data);
}
filter(pred: (data: T) => boolean): T[] {
const out: T[] = [];
for (const e of this.map.values()) if (pred(e.data)) out.push(e.data);
return out;
}
getMany(ids: string[]): T[] {
const now = Date.now();
const out: T[] = [];
for (const id of ids) {
const e = this.map.get(id);
if (e) {
e.meta.lastRead = now;
out.push(e.data);
}
}
return out;
}
entries(): StoredEntity<T>[] {
return [...this.map.values()];
}
clear(): void {
this.map.clear();
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
this.pendingWrites = 0;
this.backend.clear();
}
isStale(id: string, field: keyof T & string, now = Date.now()): boolean {
const e = this.map.get(id);
if (!e) return true;
const meta = e.meta.fields[field];
if (!meta) return true;
const cls = this.policy.classOf(field);
return now - meta.at > this.policy.maxAge[cls];
}
upsert(partial: Partial<T> & Entity, src: FieldSource, at = Date.now()): T {
const existing = this.map.get(partial.id);
const base: StoredEntity<T> = existing ?? {
data: { ...(partial as T) },
meta: { fields: {}, firstSeen: at, lastRead: at, lastFetch: at },
};
const data = { ...base.data } as T;
const fields = { ...base.meta.fields };
let changed = !existing;
for (const key of Object.keys(partial) as (keyof T & string)[]) {
if (key === "id") continue;
const incoming = (partial as T)[key];
if (incoming === undefined) continue;
const prev = fields[key];
const cls = this.policy.classOf(key);
if (prev) {
if (
this.policy.keepNonEmpty?.has(key) &&
isEmpty(incoming) &&
!isEmpty(data[key]) &&
sourcePriority(src) < sourcePriority("rest:detail")
) {
continue;
}
if (cls === "live") {
if (sourcePriority(src) < sourcePriority(prev.src)) continue;
if (at < prev.at) continue;
} else if (at < prev.at) {
continue;
}
}
data[key] = incoming;
fields[key] = { at, src };
changed = true;
}
const entity: StoredEntity<T> = {
data,
meta: {
fields,
firstSeen: base.meta.firstSeen,
lastRead: base.meta.lastRead,
lastFetch: src === "ws" ? base.meta.lastFetch : at,
},
};
this.map.set(entity.data.id, entity);
if (changed) {
this.backend.put(entity.data.id, entity);
this.scheduleSave();
this.maybeEvict();
this.emit({ type: "upsert", id: entity.data.id, entity: entity.data });
}
return entity.data;
}
upsertMany(items: (Partial<T> & Entity)[], src: FieldSource, at = Date.now()): void {
for (const item of items) this.upsert(item, src, at);
}
remove(id: string): void {
if (this.map.delete(id)) {
this.backend.remove(id);
this.scheduleSave();
}
}
stats(): RepoStats {
let totalSize = 0;
let oldestRead: number | null = null;
let newestFetch: number | null = null;
for (const e of this.map.values()) {
totalSize += roughSize(e.data);
if (oldestRead === null || e.meta.lastRead < oldestRead) oldestRead = e.meta.lastRead;
if (newestFetch === null || e.meta.lastFetch > newestFetch) newestFetch = e.meta.lastFetch;
}
return {
name: this.name,
count: this.map.size,
totalSize,
oldestRead,
newestFetch,
pendingWrites: this.pendingWrites,
backendFile: this.backend.file,
};
}
flush(): void {
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
this.backend.flush(this.map);
this.pendingWrites = 0;
}
private markLiveStaleOnLoad(): void {
const stale = 0;
for (const e of this.map.values()) {
for (const key of Object.keys(e.meta.fields)) {
if (this.policy.classOf(key as keyof T & string) === "live") {
e.meta.fields[key].at = stale;
e.meta.fields[key].src = "seed";
}
}
}
}
private maybeEvict(): void {
const overCap = this.map.size > this.maxEntries;
const due = Date.now() - this.lastEvict > EVICT_INTERVAL_MS;
if (!overCap && !due) return;
this.evict();
}
private recency(e: StoredEntity<T>): number {
return Math.max(e.meta.lastRead, e.meta.lastFetch);
}
private evict(): void {
this.lastEvict = Date.now();
const now = this.lastEvict;
if (this.maxUnreadMs > 0) {
for (const [id, e] of this.map) {
if (now - this.recency(e) > this.maxUnreadMs) {
this.map.delete(id);
this.backend.remove(id);
}
}
}
if (this.map.size <= this.maxEntries) return;
const byRecency = [...this.map.entries()].sort(
(a, b) => this.recency(a[1]) - this.recency(b[1]),
);
for (let i = 0; i < byRecency.length && this.map.size > this.maxEntries; i++) {
const id = byRecency[i][0];
this.map.delete(id);
this.backend.remove(id);
}
}
private scheduleSave(): void {
this.pendingWrites++;
if (this.saveTimer) return;
this.saveTimer = setTimeout(() => {
this.saveTimer = null;
this.backend.flush(this.map);
this.pendingWrites = 0;
}, 1000);
this.saveTimer.unref?.();
}
private emit(change: RepoChange<T>): void {
for (const fn of this.listeners) fn(change);
}
}
function isEmpty(v: unknown): boolean {
return v == null || v === "" || (Array.isArray(v) && v.length === 0);
}
function roughSize(v: unknown): number {
try {
return JSON.stringify(v).length;
} catch {
return 0;
}
}
export type { EntityMeta };
+207
View File
@@ -0,0 +1,207 @@
import type { UserProfile } from "../../shared/types/user";
import { getActiveClient, getPipelineAuthToken } from "../vrchat/client";
import type { VRChat } from "vrchat";
import { currentUser, getUser } from "../vrchat/userService";
import { listFriends } from "../vrchat/friendsService";
import { trustRankFromTags } from "../vrchat/mappers";
import { WS_STRING_FIELDS } from "./repository/fieldPolicy";
import { activeId } from "../accounts/store";
import { entityStore, type SocialSnapshot } from "./entityStore";
import { worldStore } from "./worldStore";
import { repos } from "./repository/manager";
import { broadcast } from "../windows";
import { logger } from "../debug/logger";
import { recordWsEvent } from "../debug/wsLog";
interface Pipeline {
on: (event: string, handler: (data: unknown) => void) => void;
pipeline?: { authenticate: (token: string) => Promise<void>; connected: boolean };
}
const subscribed = new WeakSet<object>();
async function connectPipeline(vrc: VRChat): Promise<void> {
if (!vrc.pipeline || vrc.pipeline.connected) return;
try {
const auth = await getPipelineAuthToken(vrc);
if (!auth) {
logger.warn("ws", "no auth cookie; pipeline not connected");
return;
}
await vrc.pipeline.authenticate(auth);
logger.info("ws", "pipeline connected");
} catch (err) {
logger.warn("ws", "pipeline connect failed", String((err as Error)?.message ?? err));
}
}
function patchFromUser(
u: Record<string, unknown>,
profile = false,
): (Partial<UserProfile> & { id: string }) | null {
const id = (u.id ?? u.userId) as string | undefined;
if (!id) return null;
const p: Partial<UserProfile> & { id: string } = { id };
for (const field of WS_STRING_FIELDS) {
if (u[field] !== undefined) p[field] = u[field] as string;
}
if (u.status !== undefined) p.status = u.status as UserProfile["status"];
if (typeof u.statusDescription === "string" && (profile || u.statusDescription !== ""))
p.statusDescription = u.statusDescription;
if (Array.isArray(u.tags)) {
p.tags = u.tags as string[];
p.trustRank = trustRankFromTags(u.tags as string[]);
}
return p;
}
function asRecord(data: unknown): Record<string, unknown> {
return (data && typeof data === "object" ? data : {}) as Record<string, unknown>;
}
function userOf(data: unknown): Record<string, unknown> {
const d = asRecord(data);
return asRecord(d.user ?? d);
}
const WS_EVENTS = [
"friend-add",
"friend-delete",
"friend-online",
"friend-active",
"friend-offline",
"friend-update",
"friend-location",
"user-update",
"user-location",
"user-badge-assigned",
"user-badge-unassigned",
"content-refresh",
"economy-update",
"modified-image-update",
"instance-queue-joined",
"instance-queue-ready",
"notification",
"response-notification",
"see-notification",
"hide-notification",
"clear-notification",
"notification-v2",
"notification-v2-update",
"notification-v2-delete",
"group-joined",
"group-left",
"group-member-updated",
"group-role-updated",
];
const HANDLED = new Set([
"friend-update",
"friend-online",
"friend-active",
"user-update",
"friend-location",
"user-location",
"friend-offline",
"friend-add",
"friend-delete",
]);
function subscribe(vrc: VRChat & Pipeline): void {
if (subscribed.has(vrc)) return;
subscribed.add(vrc);
const active = () => getActiveClient() === vrc;
for (const ev of WS_EVENTS) {
vrc.on(ev, (data: unknown) => recordWsEvent(ev, data, HANDLED.has(ev)));
}
void connectPipeline(vrc);
const patch = (data: unknown, state?: UserProfile["state"], profile = false) => {
if (!active()) return;
const p = patchFromUser(userOf(data), profile);
if (p) entityStore.upsert(state ? { ...p, state } : p);
};
vrc.on("friend-update", (d) => patch(d, undefined, true));
vrc.on("user-update", (d) => patch(d, undefined, true));
vrc.on("friend-online", (d) => patch(d, "online"));
vrc.on("friend-active", (d) => patch(d, "active"));
const location = (data: unknown) => {
if (!active()) return;
const d = asRecord(data);
const id = (d.userId ?? userOf(data).id) as string | undefined;
if (!id) return;
const fromUser = patchFromUser(userOf(data)) ?? { id };
entityStore.upsert({ ...fromUser, id, location: d.location as string, state: "online" });
};
vrc.on("friend-location", location);
vrc.on("user-location", location);
vrc.on("friend-offline", (data) => {
if (!active()) return;
const id = (asRecord(data).userId ?? userOf(data).id) as string | undefined;
if (id) entityStore.upsert({ id, state: "offline", location: "offline" });
});
vrc.on("friend-add", async (data) => {
if (!active()) return;
const id = (asRecord(data).userId ?? userOf(data).id) as string | undefined;
if (!id) return;
try {
entityStore.addFriend(await getUser(id));
} catch {}
});
vrc.on("friend-delete", (data) => {
if (!active()) return;
const id = (asRecord(data).userId ?? userOf(data).id) as string | undefined;
if (id) entityStore.removeFriend(id);
});
}
export async function seedActiveAccount(force = false): Promise<void> {
const id = activeId();
const alreadyActive = entityStore.snapshot().selfId === id;
repos.setActive(id);
if (!id) {
worldStore.reset();
entityStore.reset();
return;
}
if (!force && alreadyActive) return;
worldStore.reset();
entityStore.reset();
const vrc = getActiveClient();
if (!vrc) return;
try {
const [self, friends] = await Promise.all([currentUser(), listFriends()]);
entityStore.seed(self, friends);
logger.info("social", `seeded ${friends.length} friends`);
subscribe(vrc as VRChat & Pipeline);
} catch (err) {
logger.warn("social", "seed failed", String((err as Error)?.message ?? err));
}
}
export function socialSnapshot(): SocialSnapshot {
return entityStore.snapshot();
}
export function startSocialBridge(): void {
entityStore.onChange((c) => {
if (c.type === "seed") broadcast("social:seed", c.snapshot);
else broadcast("social:upsert", c.user);
});
worldStore.onChange((c) => {
if (c.type === "seed") broadcast("world:seed", c.snapshot);
else broadcast("world:upsert", c.world);
});
}
+68
View File
@@ -0,0 +1,68 @@
import type { World, WorldSnapshot } from "../../shared/types/world";
import type { FieldSource } from "../../shared/types/repository";
import { repos } from "./repository/manager";
export type { WorldSnapshot };
type Change = { type: "seed"; snapshot: WorldSnapshot } | { type: "upsert"; world: World };
type Listener = (change: Change) => void;
class WorldStore {
private readonly listeners = new Set<Listener>();
private byAuthor = new Map<string, Set<string>>();
private wired = false;
onChange(fn: Listener): () => void {
this.wire();
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private wire(): void {
if (this.wired || !repos.hasActive) return;
this.wired = true;
repos.active.worlds.onChange((c) => {
this.emit({ type: "upsert", world: c.entity });
});
}
setAuthorWorlds(authorId: string, worlds: World[]): void {
this.byAuthor.set(authorId, new Set(worlds.map((w) => w.id)));
repos.active.worlds.upsertMany(worlds, "rest:list");
this.emit({ type: "seed", snapshot: this.snapshot() });
}
addWorld(world: World, src: FieldSource = world.detailed ? "rest:detail" : "rest:list"): void {
repos.active.worlds.upsert(world, src);
}
authorWorlds(authorId: string): World[] {
const ids = this.byAuthor.get(authorId);
if (!ids) return [];
return repos.active.worlds.getMany([...ids]);
}
get(worldId: string): World | undefined {
return repos.active.worlds.get(worldId);
}
snapshot(): WorldSnapshot {
return {
worlds: repos.hasActive ? repos.active.worlds.all() : [],
byAuthor: Object.fromEntries([...this.byAuthor].map(([k, v]) => [k, [...v]])),
};
}
reset(): void {
this.byAuthor.clear();
this.wired = false;
this.wire();
this.emit({ type: "seed", snapshot: this.snapshot() });
}
private emit(change: Change): void {
for (const fn of this.listeners) fn(change);
}
}
export const worldStore = new WorldStore();
+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;
}
+98
View File
@@ -0,0 +1,98 @@
import { app, BrowserWindow, shell } from "electron";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import type { IpcEventChannel, IpcEvents } from "../shared/ipc";
import { TRAFFIC_LIGHT_INSET } from "../shared/window";
const __dirname = dirname(fileURLToPath(import.meta.url));
const isDev = !app.isPackaged;
let mainWindow: BrowserWindow | null = null;
let debugWindow: BrowserWindow | null = null;
export function broadcast<C extends IpcEventChannel>(channel: C, payload: IpcEvents[C]): void {
for (const w of BrowserWindow.getAllWindows()) {
if (!w.isDestroyed()) w.webContents.send(channel, payload);
}
}
function load(win: BrowserWindow, hash = ""): void {
if (isDev && process.env["ELECTRON_RENDERER_URL"]) {
void win.loadURL(process.env["ELECTRON_RENDERER_URL"] + (hash ? `#${hash}` : ""));
} else {
void win.loadFile(join(__dirname, "../renderer/index.html"), hash ? { hash } : undefined);
}
}
export function createMainWindow(): BrowserWindow {
mainWindow = new BrowserWindow({
width: 1180,
height: 760,
minWidth: 940,
minHeight: 600,
show: false,
backgroundColor: "#0d0b14",
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: TRAFFIC_LIGHT_INSET, y: TRAFFIC_LIGHT_INSET },
autoHideMenuBar: true,
webPreferences: {
preload: join(__dirname, "../preload/index.mjs"),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
mainWindow.once("ready-to-show", () => mainWindow?.show());
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
void shell.openExternal(url);
return { action: "deny" };
});
mainWindow.on("closed", () => {
mainWindow = null;
});
load(mainWindow);
return mainWindow;
}
export function focusMainWindow(): void {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
export function openDebugWindow(): void {
if (debugWindow && !debugWindow.isDestroyed()) {
debugWindow.focus();
return;
}
debugWindow = new BrowserWindow({
width: 900,
height: 720,
minWidth: 620,
minHeight: 480,
show: false,
backgroundColor: "#0d0b14",
autoHideMenuBar: true,
title: "VRC Circle — Debug",
webPreferences: {
preload: join(__dirname, "../preload/index.mjs"),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
debugWindow.once("ready-to-show", () => debugWindow?.show());
debugWindow.webContents.setWindowOpenHandler(({ url }) => {
void shell.openExternal(url);
return { action: "deny" };
});
debugWindow.on("closed", () => {
debugWindow = null;
});
load(debugWindow, "debug");
}
+9
View File
@@ -0,0 +1,9 @@
import type { VrcCircleApi } from "./index";
declare global {
interface Window {
api: VrcCircleApi;
}
}
export {};
+32
View File
@@ -0,0 +1,32 @@
import { contextBridge, ipcRenderer } from "electron";
import type { IpcRequests, IpcEvents } from "../shared/ipc";
type InvokeFn = <C extends keyof IpcRequests>(
channel: C,
...args: Parameters<IpcRequests[C]>
) => ReturnType<IpcRequests[C]>;
export interface EventApi {
on<C extends keyof IpcEvents>(channel: C, listener: (payload: IpcEvents[C]) => void): () => void;
}
const invoke = ((channel: string, ...args: unknown[]) =>
ipcRenderer.invoke(channel, ...args)) as InvokeFn;
const events: EventApi = {
on(channel, listener) {
const wrapped = (_e: unknown, payload: unknown) => listener(payload as never);
ipcRenderer.on(channel as string, wrapped);
return () => ipcRenderer.removeListener(channel as string, wrapped);
},
};
export interface VrcCircleApi {
invoke: InvokeFn;
events: EventApi;
platform: NodeJS.Platform;
}
const api: VrcCircleApi = { invoke, events, platform: process.platform };
contextBridge.exposeInMainWorld("api", api);
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; img-src 'self' https: data: vrcgallery:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src 'self' vrcgallery:; script-src 'self'"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VRC Circle</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+13
View File
@@ -0,0 +1,13 @@
import { useAuth } from "./features/auth/AuthContext";
import { LoginScreen } from "./features/auth/LoginScreen";
import { AppShell } from "./components/AppShell";
import { Loader } from "./components/ui";
export function App() {
const { status, loading, adding } = useAuth();
if (loading) return <Loader />;
const authed = status.state === "authenticated";
return authed && !adding ? <AppShell /> : <LoginScreen />;
}
+204
View File
@@ -0,0 +1,204 @@
import { useState } from "react";
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
CircleDot,
ExternalLink,
Images,
Search,
Settings,
SlidersHorizontal,
Sparkles,
UserCog,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { ProfileView } from "../features/profile/ProfileView";
import { WorldView } from "../features/world/WorldView";
import { AccountSettingsView } from "../features/account/AccountSettingsView";
import { SearchView } from "../features/search/SearchView";
import { SettingsView } from "../features/settings/SettingsView";
import { EnhancementsView } from "../features/enhancements/EnhancementsView";
import { GalleryView } from "../features/gallery/GalleryView";
import { FriendsSidebar } from "../features/friends/FriendsSidebar";
import { AccountSwitcher } from "../features/auth/AccountSwitcher";
import { LaunchButton } from "../features/game/LaunchButton";
import { NavProvider, useNav, type View } from "../features/navigation/NavContext";
import { useI18n } from "../lib/i18n";
import { api } from "../lib/api";
import "../styles/app-shell.css";
export function AppShell() {
return (
<NavProvider>
<Shell />
</NavProvider>
);
}
type NavItem = {
id: string;
label: string;
icon: LucideIcon;
onClick: () => void;
kind?: View["kind"];
external?: boolean;
};
function Shell() {
const nav = useNav();
const { t } = useI18n();
const [leftOpen, setLeftOpen] = useState(true);
const [friendsOpen, setFriendsOpen] = useState(true);
const navItems: NavItem[] = [
{
id: "search",
label: t("nav:search"),
icon: Search,
kind: "search",
onClick: () => nav.openSearch(),
},
{
id: "gallery",
label: t("nav:gallery"),
icon: Images,
kind: "gallery",
onClick: () => nav.openGallery(),
},
{
id: "enhancements",
label: t("nav:enhancements"),
icon: Sparkles,
kind: "enhancements",
onClick: () => nav.openEnhancements(),
},
{
id: "account",
label: t("nav:account"),
icon: UserCog,
kind: "account",
onClick: () => nav.openAccount(),
},
{
id: "settings",
label: t("nav:settings"),
icon: SlidersHorizontal,
kind: "settings",
onClick: () => nav.openSettings(),
},
{
id: "debug",
label: t("nav:debug"),
icon: Settings,
external: true,
onClick: () => void api.debug.openWindow(),
},
];
const openProfile = (id: "me" | string) => nav.openUser(id);
const stageKey =
nav.current.kind === "user" || nav.current.kind === "world"
? `${nav.current.kind}:${nav.current.id}`
: nav.current.kind;
return (
<div className="shell">
<header className="topbar">
<div className="topbar__brand">
<span className="topbar__mark" aria-hidden>
<CircleDot size={15} />
</span>
VRC Circle
</div>
<div className="topbar__actions">
<LaunchButton />
</div>
</header>
<div
className={`body ${leftOpen ? "" : "is-left-collapsed"} ${
friendsOpen ? "" : "is-right-collapsed"
}`}
>
<aside className="leftbar">
<div className="leftbar__scroll">
<nav className="leftbar__nav">
{navItems.map((item) => {
const Icon = item.icon;
const active = !item.external && nav.current.kind === item.kind;
return (
<button
key={item.id}
className={`navitem ${active ? "is-active" : ""}`}
onClick={item.onClick}
title={item.label}
>
<span className="navitem__ico">
<Icon size={16} />
</span>
<span className="navitem__label">{item.label}</span>
{item.external ? (
<span className="navitem__hint">
<ExternalLink size={13} />
</span>
) : null}
</button>
);
})}
</nav>
</div>
<div className="leftbar__footer">
<AccountSwitcher />
</div>
</aside>
<main className="stage">
{nav.canBack ? (
<button onClick={nav.back} className="stage__back" aria-label="Go back">
<ArrowLeft size={16} /> Back
</button>
) : null}
<div key={stageKey} className="stage__inner animate-rise">
{nav.current.kind === "world" ? (
<WorldView worldId={nav.current.id} />
) : nav.current.kind === "account" ? (
<AccountSettingsView />
) : nav.current.kind === "settings" ? (
<SettingsView />
) : nav.current.kind === "enhancements" ? (
<EnhancementsView />
) : nav.current.kind === "gallery" ? (
<GalleryView />
) : nav.current.kind === "search" ? (
<SearchView />
) : (
<ProfileView target={nav.current.id} />
)}
</div>
</main>
<FriendsSidebar onOpen={openProfile} />
<button
className="edge-toggle edge-toggle--left"
onClick={() => setLeftOpen((v) => !v)}
title={leftOpen ? t("nav:hideNav") : t("nav:showNav")}
aria-label={t("nav:toggleNav")}
>
{leftOpen ? <ChevronLeft size={18} /> : <ChevronRight size={18} />}
</button>
<button
className="edge-toggle edge-toggle--right"
onClick={() => setFriendsOpen((v) => !v)}
title={friendsOpen ? "Hide friends" : "Show friends"}
aria-label="Toggle friends sidebar"
>
{friendsOpen ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div>
</div>
);
}
+27
View File
@@ -0,0 +1,27 @@
function initials(name?: string): string {
return (name ?? "?").trim().slice(0, 2).toUpperCase() || "?";
}
export function Avatar({
src,
name,
size = 30,
className = "",
}: {
src?: string;
name?: string;
size?: number;
className?: string;
}) {
const style = { width: size, height: size, borderRadius: "50%" };
return src ? (
<img src={src} alt="" style={style} className={`shrink-0 object-cover ${className}`} />
) : (
<span
style={style}
className={`avatar-fallback grid shrink-0 place-items-center bg-surface-hover font-semibold uppercase text-muted ${className}`}
>
{initials(name)}
</span>
);
}
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
const TONE = {
neutral: "var(--muted)",
accent: "var(--accent)",
success: "var(--status-active)",
warn: "var(--status-ask)",
danger: "var(--danger)",
} as const;
export function Badge({
children,
tone = "neutral",
}: {
children: ReactNode;
tone?: keyof typeof TONE;
}) {
const color = TONE[tone];
return (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide"
style={{ color, background: `color-mix(in srgb, ${color} 15%, transparent)` }}
>
{children}
</span>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { ReactNode } from "react";
export function Banner({ children, className = "" }: { children: ReactNode; className?: string }) {
return (
<div
className={`flex animate-pop items-center gap-2.5 rounded-sm border px-[13px] py-2.5 text-[13.5px] ${className}`}
style={{
color: "color-mix(in srgb, var(--danger) 75%, var(--text))",
background: "color-mix(in srgb, var(--danger) 12%, transparent)",
borderColor: "color-mix(in srgb, var(--danger) 35%, transparent)",
}}
>
{children}
</div>
);
}
+60
View File
@@ -0,0 +1,60 @@
import type { ButtonHTMLAttributes } from "react";
const BASE =
"inline-flex items-center justify-center gap-2 rounded-full px-[18px] py-2.5 text-sm font-semibold transition-[transform,background,border-color,box-shadow] duration-[var(--dur)] ease-[var(--ease)] disabled:cursor-not-allowed disabled:opacity-50 active:not-disabled:scale-[0.98]";
const VARIANT = {
primary:
"bg-accent text-on-accent hover:not-disabled:brightness-105 hover:not-disabled:shadow-[0_8px_20px_-10px_var(--accent)]",
ghost:
"border border-border bg-surface-2 text-text hover:not-disabled:border-border-strong hover:not-disabled:bg-surface-hover",
danger:
"bg-danger text-on-accent hover:not-disabled:brightness-105 hover:not-disabled:shadow-[0_8px_20px_-10px_var(--danger)]",
link: "bg-transparent text-text hover:not-disabled:text-accent",
} as const;
export function Button({
children,
variant = "primary",
loading,
block,
className = "",
...rest
}: ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: keyof typeof VARIANT;
loading?: boolean;
block?: boolean;
}) {
return (
<button
className={`${BASE} ${VARIANT[variant]} ${block ? "w-full" : ""} ${className}`}
disabled={loading || rest.disabled}
{...rest}
>
{loading ? (
<span className="size-3.5 animate-[spin_0.7s_linear_infinite] rounded-full border-2 border-[color-mix(in_srgb,currentColor_35%,transparent)] border-t-current" />
) : null}
<span className="inline-flex items-center gap-2">{children}</span>
</button>
);
}
export function IconButton({
active,
className = "",
children,
...rest
}: ButtonHTMLAttributes<HTMLButtonElement> & { active?: boolean }) {
return (
<button
className={`grid size-[38px] shrink-0 place-items-center rounded-sm border bg-surface-2 text-muted transition-[background,color,border-color] duration-[var(--dur)] ease-[var(--ease)] hover:bg-surface-hover hover:text-text ${
active
? "border-[color-mix(in_srgb,var(--accent)_40%,var(--border))] text-accent"
: "border-border hover:border-border-strong"
} ${className}`}
{...rest}
>
{children}
</button>
);
}
@@ -0,0 +1,33 @@
import { useState, type ReactNode } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
export function CollapsibleCard({
title,
count,
defaultOpen = true,
children,
}: {
title: string;
count?: number | string;
defaultOpen?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<section className="rounded-xl border border-border bg-surface-2 p-5 shadow-sm">
<button
onClick={() => setOpen((v) => !v)}
className={`flex w-full items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-faint ${
open ? "mb-3" : ""
}`}
>
<span className="text-faint">
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
{title}
{count !== undefined ? <span className="text-faint/70">{count}</span> : null}
</button>
{open ? children : null}
</section>
);
}
+19
View File
@@ -0,0 +1,19 @@
import type { InputHTMLAttributes, ReactNode } from "react";
export const INPUT_CLASS =
"w-full rounded-sm border border-border bg-surface-2 px-3 py-2.5 text-text outline-none transition-[border-color,box-shadow,background] duration-[var(--dur)] ease-[var(--ease)] placeholder:text-faint focus:border-accent focus:bg-surface focus:shadow-[0_0_0_3px_var(--accent-weak)]";
export function Field({
label,
hint,
className = "",
...rest
}: InputHTMLAttributes<HTMLInputElement> & { label: string; hint?: ReactNode }) {
return (
<label className="flex flex-col gap-[7px]">
<span className="text-xs font-semibold text-muted">{label}</span>
<input className={`${INPUT_CLASS} ${className}`} {...rest} />
{hint ? <span className="text-[12.5px] text-muted">{hint}</span> : null}
</label>
);
}
@@ -0,0 +1,14 @@
import type { ReactNode } from "react";
export function LinkPill({ href, children }: { href: string; children: ReactNode }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="rounded-full border border-border bg-surface px-3 py-1 text-[12.5px] font-semibold text-muted transition-colors hover:border-accent hover:text-accent"
>
{children}
</a>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { CircleDot } from "lucide-react";
export function Loader({ size = 40, className = "" }: { size?: number; className?: string }) {
return (
<div className={`boot ${className}`}>
<span className="boot__mark" aria-hidden>
<CircleDot size={size} />
</span>
</div>
);
}
+97
View File
@@ -0,0 +1,97 @@
import { useEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { Button } from "./Button";
type ModalProps = {
open: boolean;
onClose: () => void;
title: string;
icon?: ReactNode;
children?: ReactNode;
danger?: boolean;
confirmLabel?: string;
cancelLabel?: string;
onConfirm?: () => void;
confirmLoading?: boolean;
confirmDisabled?: boolean;
};
export function Modal({
open,
onClose,
title,
icon,
children,
danger,
confirmLabel,
cancelLabel = "Cancel",
onConfirm,
confirmLoading,
confirmDisabled,
}: ModalProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div
className="fixed inset-0 z-50 grid place-items-center p-4"
role="dialog"
aria-modal="true"
aria-label={title}
>
<button
aria-hidden
tabIndex={-1}
onClick={onClose}
className="absolute inset-0 animate-[fade-in_var(--dur)_var(--ease-out)_both] bg-[color-mix(in_srgb,var(--surface)_30%,#000_55%)] backdrop-blur-[2px]"
/>
<div className="animate-[pop-in_var(--dur)_var(--ease-out)_both] relative w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-[0_24px_60px_-20px_rgba(0,0,0,0.5)]">
<div className="mb-3 flex items-start justify-between gap-3">
<h2
className={`flex items-center gap-2 text-[15px] font-semibold ${
danger ? "text-danger" : "text-text"
}`}
>
{icon}
{title}
</h2>
<button
onClick={onClose}
aria-label="Close"
className="grid size-7 shrink-0 place-items-center rounded-md text-faint transition-colors hover:bg-surface-hover hover:text-text"
>
<X size={16} />
</button>
</div>
<div className="text-[13px] text-muted">{children}</div>
{onConfirm ? (
<div className="mt-5 flex justify-end gap-2.5">
<Button variant="ghost" onClick={onClose} disabled={confirmLoading}>
{cancelLabel}
</Button>
<Button
variant={danger ? "danger" : "primary"}
onClick={onConfirm}
loading={confirmLoading}
disabled={confirmDisabled}
>
{confirmLabel}
</Button>
</div>
) : null}
</div>
</div>,
document.body,
);
}
+28
View File
@@ -0,0 +1,28 @@
import type { ReactNode } from "react";
export function Panel({
title,
meta,
action,
children,
className = "",
}: {
title: ReactNode;
meta?: ReactNode;
action?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<section
className={`flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-surface ${className}`}
>
<header className="flex items-center gap-3 border-b border-border px-4 py-3">
<h2 className="text-sm font-bold">{title}</h2>
{meta ? <span className="text-xs text-faint">{meta}</span> : null}
{action ? <div className="ml-auto">{action}</div> : null}
</header>
{children}
</section>
);
}
@@ -0,0 +1,21 @@
import type { UserProfile } from "../../../../shared/types/user";
import { avatarOf, isOnline, statusMeta } from "../../lib/vrchat";
import { Avatar } from "./Avatar";
import { StatusDot } from "./StatusDot";
export function PresenceAvatar({ user, size = 32 }: { user: UserProfile; size?: number }) {
const status = statusMeta[isOnline(user) ? user.status : "offline"];
const dot = Math.max(8, Math.round(size * 0.32));
return (
<span className="relative shrink-0 leading-none">
<Avatar src={avatarOf(user)} name={user.displayName} size={size} />
<StatusDot
color={status.color}
size={dot}
ring="var(--surface)"
title={status.label}
className="absolute -bottom-0.5 -right-0.5"
/>
</span>
);
}
@@ -0,0 +1,49 @@
import { useState, type ReactNode } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
const HEADING = "text-[11px] font-semibold uppercase tracking-wide text-faint";
export function Section({
title,
children,
collapsible,
defaultOpen = true,
}: {
title: string;
children: ReactNode;
collapsible?: boolean;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(collapsible ? defaultOpen : true);
return (
<section className="rounded-xl border border-border bg-surface-2 p-5 shadow-sm">
{collapsible ? (
<button
onClick={() => setOpen((v) => !v)}
className={`flex w-full items-center gap-1.5 ${HEADING} ${open ? "mb-3" : ""}`}
>
<span className="text-faint">
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
{title}
</button>
) : (
<h3 className={`mb-3 ${HEADING}`}>{title}</h3>
)}
{open ? <div className="flex flex-col gap-3">{children}</div> : null}
</section>
);
}
export function Fact({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
return (
<div>
<dt className="mb-0.5 text-[11px] uppercase tracking-wide text-faint">{label}</dt>
<dd
className={mono ? "break-all font-mono text-[12px] text-muted" : "text-[13.5px] text-text"}
>
{value}
</dd>
</div>
);
}
@@ -0,0 +1,17 @@
export function SkeletonGrid({
count,
grid = "grid grid-cols-2 gap-3 sm:grid-cols-3",
item = "sk aspect-video rounded-lg",
}: {
count: number;
grid?: string;
item?: string;
}) {
return (
<div className={grid}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className={item} />
))}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import type { ReactNode } from "react";
export function Stat({
label,
value,
tone,
}: {
label: ReactNode;
value: ReactNode;
tone?: string;
}) {
return (
<div className="rounded-md border border-border bg-surface-2 px-3 py-2">
<div className="text-[10.5px] uppercase tracking-wide text-faint">{label}</div>
<div
className="mt-0.5 text-lg font-bold tabular-nums"
style={tone ? { color: tone } : undefined}
>
{value}
</div>
</div>
);
}
@@ -0,0 +1,27 @@
import type { ReactNode } from "react";
export function StatTile({
icon,
label,
value,
live,
}: {
icon?: ReactNode;
label: string;
value: string;
live?: boolean;
}) {
return (
<div className="rounded-xl border border-border bg-surface-2 p-4 shadow-sm">
<div className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-faint">
{icon} {label}
</div>
<div
className="mt-1 text-2xl font-bold tabular-nums"
style={live ? { color: "var(--status-active)" } : undefined}
>
{value}
</div>
</div>
);
}
@@ -0,0 +1,22 @@
import type { HTMLAttributes } from "react";
export function StatusDot({
color,
size = 11,
ring,
className = "",
...rest
}: { color: string; size?: number; ring?: string } & HTMLAttributes<HTMLSpanElement>) {
return (
<span
className={`block rounded-full ${className}`}
style={{
width: size,
height: size,
background: color,
border: ring ? `2px solid ${ring}` : undefined,
}}
{...rest}
/>
);
}
+33
View File
@@ -0,0 +1,33 @@
export function Tabs<T extends string>({
tabs,
active,
onChange,
}: {
tabs: { id: T; label: string }[];
active: T;
onChange: (id: T) => void;
}) {
return (
<div role="tablist" className="flex items-center gap-1 border-b border-border">
{tabs.map((t) => {
const selected = t.id === active;
return (
<button
key={t.id}
role="tab"
aria-selected={selected}
onClick={() => onChange(t.id)}
className={`relative -mb-px px-4 py-2.5 text-[13px] font-semibold transition-colors ${
selected ? "text-accent" : "text-muted hover:text-text"
}`}
>
{t.label}
{selected ? (
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
) : null}
</button>
);
})}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { ReactNode } from "react";
export function Tag({ children, color }: { children: ReactNode; color?: string }) {
const c = color ?? "var(--muted)";
return (
<span
className="inline-flex items-center gap-1.5 rounded-full border px-[9px] py-[3px] text-[11.5px] font-semibold"
style={{
color: c,
background: `color-mix(in srgb, ${c} 14%, transparent)`,
borderColor: `color-mix(in srgb, ${c} 30%, transparent)`,
}}
>
{children}
</span>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { Check } from "lucide-react";
export function Toggle({
checked,
onChange,
disabled,
icon,
className = "",
}: {
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
icon?: boolean;
className?: string;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative h-6 w-10 shrink-0 rounded-full border-0 p-0 transition-colors ${
checked ? "bg-accent" : "bg-surface-hover"
} ${disabled ? "cursor-not-allowed opacity-50" : ""} ${className}`}
>
<span
className="absolute left-0.5 top-0.5 flex size-5 items-center justify-center rounded-full shadow transition-transform"
style={{
background: "var(--on-accent)",
transform: checked ? "translateX(16px)" : "translateX(0)",
}}
>
{icon && checked ? <Check size={12} className="text-accent" /> : null}
</span>
</button>
);
}
+19
View File
@@ -0,0 +1,19 @@
export { Loader } from "./Loader";
export { Button, IconButton } from "./Button";
export { Field, INPUT_CLASS } from "./Field";
export { Avatar } from "./Avatar";
export { Tag } from "./Tag";
export { Badge } from "./Badge";
export { Banner } from "./Banner";
export { StatusDot } from "./StatusDot";
export { Panel } from "./Panel";
export { Tabs } from "./Tabs";
export { Stat } from "./Stat";
export { PresenceAvatar } from "./PresenceAvatar";
export { CollapsibleCard } from "./CollapsibleCard";
export { Modal } from "./Modal";
export { Toggle } from "./Toggle";
export { Section, Fact } from "./Section";
export { StatTile } from "./StatTile";
export { SkeletonGrid } from "./SkeletonGrid";
export { LinkPill } from "./LinkPill";
@@ -0,0 +1,91 @@
import { useState } from "react";
import { Trans } from "react-i18next";
import { useI18n } from "../../lib/i18n";
import { Banner, Loader, Tabs } from "../../components/ui";
import { useAccountSettings } from "./useAccountSettings";
import { DisplayNameSection } from "./sections/DisplayNameSection";
import { EmailSection } from "./sections/EmailSection";
import { PasswordSection } from "./sections/PasswordSection";
import { AccountLinksSection } from "./sections/AccountLinksSection";
import { TwoFactorSection } from "./sections/TwoFactorSection";
import { AgeVerificationSection } from "./sections/AgeVerificationSection";
import { PrivacySection } from "./sections/PrivacySection";
import { ContentGatingSection } from "./sections/ContentGatingSection";
import { UserDataSection } from "./sections/UserDataSection";
import { DangerZoneSection } from "./sections/DangerZoneSection";
const SHELL = "mx-auto flex w-full max-w-[760px] flex-col gap-[18px] px-12 pb-16 pt-10";
const SECTIONS = "animate-rise flex flex-col gap-[18px]";
type AccountTab = "account" | "security" | "privacy" | "data";
export function AccountSettingsView() {
const { t } = useI18n();
const { state, set } = useAccountSettings();
const [tab, setTab] = useState<AccountTab>("account");
if (state.status === "loading") return <Loader className="absolute inset-0" />;
if (state.status === "error")
return (
<div className={SHELL}>
<Banner>{state.message}</Banner>
</div>
);
const s = state.settings;
return (
<div className={SHELL}>
<header>
<h1 className="text-[26px] font-bold tracking-[-0.4px]">{t("account:title")}</h1>
<p className="mt-1 text-[13.5px] text-muted">
<Trans
i18nKey="account:subtitle"
values={{ name: s.displayName }}
components={[<span className="font-semibold text-text" />]}
/>
</p>
</header>
<Tabs
tabs={[
{ id: "account", label: t("account:tabs.account") },
{ id: "security", label: t("account:tabs.security") },
{ id: "privacy", label: t("account:tabs.privacy") },
{ id: "data", label: t("account:tabs.data") },
]}
active={tab}
onChange={setTab}
/>
{tab === "account" ? (
<div className={SECTIONS}>
<DisplayNameSection settings={s} onChange={set} />
<EmailSection settings={s} onChange={set} />
<PasswordSection settings={s} onChange={set} />
<AccountLinksSection settings={s} />
</div>
) : null}
{tab === "security" ? (
<div className={SECTIONS}>
<TwoFactorSection settings={s} onChange={set} />
<AgeVerificationSection settings={s} />
</div>
) : null}
{tab === "privacy" ? (
<div className={SECTIONS}>
<PrivacySection settings={s} onChange={set} />
<ContentGatingSection settings={s} onChange={set} />
</div>
) : null}
{tab === "data" ? (
<div className={SECTIONS}>
<UserDataSection />
<DangerZoneSection settings={s} onChange={set} />
</div>
) : null}
</div>
);
}
@@ -0,0 +1,41 @@
import type { AccountSettings } from "../../../../../shared/types/settings";
import { useI18n } from "../../../lib/i18n";
import { Badge } from "../../../components/ui";
import { ExternalButton, Section, WEBSITE_ACCOUNT } from "../ui";
export function AccountLinksSection({ settings }: { settings: AccountSettings }) {
const { t } = useI18n();
return (
<Section
title={t("account:linkedAccounts.title")}
description={t("account:linkedAccounts.description")}
>
<div className="flex flex-col gap-2.5">
<LinkRow name="Discord" link={settings.discord} />
<LinkRow name="Google" link={settings.google} />
</div>
<div className="mt-4">
<ExternalButton href={WEBSITE_ACCOUNT}>{t("account:linkedAccounts.manage")}</ExternalButton>
</div>
</Section>
);
}
function LinkRow({ name, link }: { name: string; link: AccountSettings["discord"] }) {
const { t } = useI18n();
return (
<div className="flex items-center justify-between rounded-lg border border-border bg-surface-2 px-4 py-3">
<div>
<div className="text-[13.5px] font-semibold">{name}</div>
{link.linked && link.label ? (
<div className="text-[12px] text-muted">{link.label}</div>
) : null}
</div>
{link.linked ? (
<Badge tone="success">{t("account:linkedAccounts.linked")}</Badge>
) : (
<Badge tone="neutral">{t("account:linkedAccounts.notLinked")}</Badge>
)}
</div>
);
}
@@ -0,0 +1,30 @@
import type { AccountSettings } from "../../../../../shared/types/settings";
import { useI18n } from "../../../lib/i18n";
import { Badge } from "../../../components/ui";
import { ExternalButton, Section, WEBSITE_ACCOUNT } from "../ui";
export function AgeVerificationSection({ settings }: { settings: AccountSettings }) {
const { t } = useI18n();
const verified = settings.ageVerified || settings.ageVerificationStatus !== "hidden";
return (
<Section
title={t("account:ageVerification.title")}
description={t("account:ageVerification.description")}
>
<div className="flex flex-wrap items-center gap-3">
{verified ? (
<Badge tone="success">
{settings.ageVerificationStatus === "18+"
? t("account:ageVerification.verified18")
: t("account:ageVerification.verified")}
</Badge>
) : (
<Badge tone="neutral">{t("account:ageVerification.notVerified")}</Badge>
)}
<ExternalButton href={WEBSITE_ACCOUNT}>
{verified ? t("account:ageVerification.manage") : t("account:ageVerification.verify")}
</ExternalButton>
</div>
</Section>
);
}
@@ -0,0 +1,48 @@
import type { ContentFilterKey } from "../../../../../shared/types/settings";
import { api } from "../../../lib/api";
import { useI18n } from "../../../lib/i18n";
import { Notice, Section, ToggleRow, useAsync, type SectionProps } from "../ui";
const FILTER_ORDER: ContentFilterKey[] = [
"content_sex",
"content_adult",
"content_violence",
"content_gore",
"content_horror",
];
export function ContentGatingSection({ settings, onChange }: SectionProps) {
const { t } = useI18n();
const { busy, error, run } = useAsync();
const active = new Set(settings.contentFilters);
function toggle(key: ContentFilterKey, on: boolean) {
const next = FILTER_ORDER.filter((k) => (k === key ? on : active.has(k)));
void run(api.settings.contentFilters(next), { onOk: onChange });
}
return (
<Section
title={t("account:contentGating.title")}
description={t("account:contentGating.description")}
>
{settings.contentFiltersLocked ? (
<p className="mb-2 text-[12.5px] text-faint">{t("account:contentGating.locked")}</p>
) : null}
<div className="divide-y divide-border">
{FILTER_ORDER.map((key) => (
<ToggleRow
key={key}
label={t("account:contentGating.filterLabel", {
label: t(`account:contentGating.filters.${key}`),
})}
checked={active.has(key)}
onChange={(on) => toggle(key, on)}
disabled={busy || settings.contentFiltersLocked}
/>
))}
</div>
<Notice error={error} />
</Section>
);
}
@@ -0,0 +1,57 @@
import { useState } from "react";
import { AlertTriangle } from "lucide-react";
import { Trans } from "react-i18next";
import { api } from "../../../lib/api";
import { useI18n } from "../../../lib/i18n";
import { Button, Field } from "../../../components/ui";
import { Notice, Section, useAsync, type SectionProps } from "../ui";
export function DangerZoneSection({ settings, onChange }: SectionProps) {
const { t } = useI18n();
const { busy, error, run } = useAsync();
const [confirmText, setConfirmText] = useState("");
const canDelete = confirmText.trim().toUpperCase() === "DELETE";
async function remove() {
await run(api.settings.deleteAccount(), {
onOk: onChange,
okMsg: t("account:dangerZone.scheduledOk"),
});
setConfirmText("");
}
if (settings.accountDeletionDate) {
return (
<Section title={t("account:dangerZone.title")} icon={<AlertTriangle size={16} />} danger>
<p className="text-[13px]">
<Trans
i18nKey="account:dangerZone.scheduled"
values={{ date: new Date(settings.accountDeletionDate).toLocaleDateString() }}
components={[<span className="font-semibold" />]}
/>
</p>
</Section>
);
}
return (
<Section
title={t("account:dangerZone.title")}
icon={<AlertTriangle size={16} />}
description={t("account:dangerZone.description")}
danger
>
<div className="flex flex-wrap items-end gap-2.5">
<Field
label={t("account:dangerZone.confirmField")}
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
/>
<Button variant="danger" onClick={remove} loading={busy} disabled={!canDelete}>
{t("account:dangerZone.delete")}
</Button>
</div>
<Notice error={error} />
</Section>
);
}
@@ -0,0 +1,198 @@
import { useState } from "react";
import { Clock } from "lucide-react";
import { Trans } from "react-i18next";
import { api } from "../../../lib/api";
import { formatDate } from "../../../lib/format";
import { useI18n } from "../../../lib/i18n";
import { Button, Field, Modal } from "../../../components/ui";
import {
Notice,
Section,
addDays,
daysSince,
lastChangedLabel,
useAsync,
type SectionProps,
} from "../ui";
export function DisplayNameSection({ settings, onChange }: SectionProps) {
const { t } = useI18n();
const [name, setName] = useState(settings.displayName);
const [password, setPassword] = useState("");
const [confirmRevert, setConfirmRevert] = useState(false);
const { busy, error, ok, run } = useAsync();
const cooldownDays = settings.supporter ? 30 : 90;
const changedDaysAgo = daysSince(settings.displayNameChangedAt);
const inCooldown = changedDaysAgo !== null && changedDaysAgo < cooldownDays;
const daysLeft = inCooldown ? cooldownDays - changedDaysAgo : 0;
const canRevert =
settings.previousDisplayName != null && changedDaysAgo !== null && changedDaysAgo <= 90;
const dirty = name.trim() !== settings.displayName && name.trim().length > 0;
async function submit() {
await run(api.settings.displayName(name.trim(), password), {
onOk: (next) => {
onChange(next);
setName(next.displayName);
setPassword("");
},
okMsg: t("account:displayName.updated"),
});
}
async function revert() {
setConfirmRevert(false);
await run(api.settings.revertDisplayName(password), {
onOk: (next) => {
onChange(next);
setName(next.displayName);
setPassword("");
},
okMsg: t("account:displayName.reverted"),
});
}
return (
<Section
title={t("account:displayName.title")}
description={
settings.supporter
? t("account:displayName.descriptionSupporter")
: t("account:displayName.description")
}
>
{inCooldown ? (
<CooldownNotice
changedAt={settings.displayNameChangedAt!}
changedDaysAgo={changedDaysAgo!}
cooldownDays={cooldownDays}
daysLeft={daysLeft}
supporter={settings.supporter}
canRevert={canRevert}
/>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<Field
label={t("account:displayName.field")}
value={name}
disabled={inCooldown}
onChange={(e) => setName(e.target.value)}
/>
<Field
label={t("account:common.currentPassword")}
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2.5">
{!inCooldown ? (
<Button onClick={submit} loading={busy} disabled={!dirty || !password}>
{t("account:displayName.save")}
</Button>
) : null}
{canRevert ? (
<Button
variant="ghost"
onClick={() => setConfirmRevert(true)}
loading={busy}
disabled={!password}
title={!password ? t("account:displayName.revertNeedsPassword") : undefined}
>
{t("account:displayName.revertTo", { name: settings.previousDisplayName })}
</Button>
) : null}
{canRevert && !password ? (
<span className="text-[12px] text-faint">
{t("account:displayName.enableRevertHint")}
</span>
) : null}
{!inCooldown && lastChangedLabel(t, settings.displayNameChangedAt) ? (
<span className="text-[12px] text-faint">
{lastChangedLabel(t, settings.displayNameChangedAt)}
</span>
) : null}
</div>
<Notice error={error} ok={ok} />
<Modal
open={confirmRevert}
onClose={() => setConfirmRevert(false)}
title={t("account:displayName.revertModal.title")}
icon={<Clock size={16} />}
confirmLabel={t("account:displayName.revertModal.confirm")}
onConfirm={revert}
confirmLoading={busy}
>
<Trans
i18nKey="account:displayName.revertModal.body"
values={{ name: settings.previousDisplayName, days: cooldownDays }}
components={[
<span className="font-semibold text-text" />,
<span className="font-semibold text-text" />,
]}
/>
</Modal>
</Section>
);
}
function CooldownNotice({
changedAt,
changedDaysAgo,
cooldownDays,
daysLeft,
supporter,
canRevert,
}: {
changedAt: string;
changedDaysAgo: number;
cooldownDays: number;
daysLeft: number;
supporter: boolean;
canRevert: boolean;
}) {
const { t } = useI18n();
const ago =
changedDaysAgo <= 0
? t("account:displayName.cooldown.agoToday")
: t("account:displayName.cooldown.agoDays", { count: changedDaysAgo });
const left = t("account:displayName.cooldown.left", { count: daysLeft });
return (
<div className="mb-4 flex gap-3 rounded-lg border border-border bg-surface p-3.5">
<Clock size={16} className="mt-0.5 shrink-0 text-muted" />
<div className="text-[12.5px] leading-relaxed">
<div className="font-semibold text-text">{t("account:displayName.cooldown.heading")}</div>
<p className="mt-1 text-muted">
<Trans
i18nKey={
supporter
? "account:displayName.cooldown.bodySupporter"
: "account:displayName.cooldown.body"
}
values={{
date: formatDate(changedAt),
ago,
days: cooldownDays,
unlockDate: formatDate(addDays(changedAt, cooldownDays)),
left,
}}
components={[
<span className="font-semibold text-text" />,
<span className="font-semibold text-text" />,
<span className="font-semibold text-text" />,
]}
/>
</p>
{canRevert ? (
<p className="mt-1.5 text-muted">{t("account:displayName.cooldown.canRevert")}</p>
) : null}
{!supporter ? (
<p className="mt-1.5 text-muted">{t("account:displayName.cooldown.upsell")}</p>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,64 @@
import { useState } from "react";
import { api } from "../../../lib/api";
import { useI18n } from "../../../lib/i18n";
import { Button, Field } from "../../../components/ui";
import { Notice, Section, useAsync, type SectionProps } from "../ui";
export function EmailSection({ settings, onChange }: SectionProps) {
const { t } = useI18n();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const { busy, error, ok, run } = useAsync();
async function submit() {
await run(api.settings.email(email.trim(), password), {
onOk: (next) => {
onChange(next);
setEmail("");
setPassword("");
},
okMsg: t("account:email.confirmSent"),
});
}
return (
<Section
title={t("account:email.title")}
description={
<>
{t("account:email.current")}
<span className="font-mono text-text">{settings.email || t("account:email.none")}</span>
{settings.emailVerified ? null : t("account:email.unverified")}
{settings.pendingEmail ? (
<>
{t("account:email.pending")}
<span className="font-mono text-text">{settings.pendingEmail}</span>
</>
) : null}
</>
}
>
<div className="grid gap-3 sm:grid-cols-2">
<Field
label={t("account:email.field")}
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Field
label={t("account:common.currentPassword")}
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
<div className="mt-3">
<Button onClick={submit} loading={busy} disabled={!email.includes("@") || !password}>
{t("account:email.change")}
</Button>
</div>
<Notice error={error} ok={ok} />
</Section>
);
}
@@ -0,0 +1,80 @@
import { useState } from "react";
import { api } from "../../../lib/api";
import { useI18n } from "../../../lib/i18n";
import { Button, Field } from "../../../components/ui";
import {
ExternalButton,
Notice,
Section,
WEBSITE_ACCOUNT,
useAsync,
type SectionProps,
} from "../ui";
export function PasswordSection({ settings, onChange }: SectionProps) {
const { t } = useI18n();
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [confirm, setConfirm] = useState("");
const { busy, error, ok, run } = useAsync();
const mismatch = confirm.length > 0 && next !== confirm;
const valid = current.length > 0 && next.length >= 8 && next === confirm;
async function submit() {
await run(api.settings.password(current, next), {
onOk: (res) => {
onChange(res);
setCurrent("");
setNext("");
setConfirm("");
},
okMsg: t("account:password.changed"),
});
}
if (settings.usesGeneratedPassword) {
return (
<Section
title={t("account:password.title")}
description={t("account:password.generatedDescription")}
>
<ExternalButton href={WEBSITE_ACCOUNT}>{t("account:password.manageSignIn")}</ExternalButton>
</Section>
);
}
return (
<Section title={t("account:password.title")} description={t("account:password.description")}>
<div className="grid gap-3 sm:grid-cols-3">
<Field
label={t("account:common.currentPassword")}
type="password"
autoComplete="current-password"
value={current}
onChange={(e) => setCurrent(e.target.value)}
/>
<Field
label={t("account:password.new")}
type="password"
autoComplete="new-password"
value={next}
onChange={(e) => setNext(e.target.value)}
/>
<Field
label={t("account:password.confirm")}
type="password"
autoComplete="new-password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
hint={mismatch ? t("account:password.mismatch") : undefined}
/>
</div>
<div className="mt-3">
<Button onClick={submit} loading={busy} disabled={!valid}>
{t("account:password.change")}
</Button>
</div>
<Notice error={error} ok={ok} />
</Section>
);
}
@@ -0,0 +1,38 @@
import { api } from "../../../lib/api";
import { useI18n } from "../../../lib/i18n";
import { Notice, Section, ToggleRow, useAsync, type SectionProps } from "../ui";
export function PrivacySection({ settings, onChange }: SectionProps) {
const { t } = useI18n();
const { busy, error, run } = useAsync();
function setShared(show: boolean) {
void run(api.settings.privacy({ sharedConnectionsHidden: !show }), { onOk: onChange });
}
function setDiscord(show: boolean) {
void run(api.settings.privacy({ discordFriendsHidden: !show }), { onOk: onChange });
}
return (
<Section title={t("account:privacy.title")} description={t("account:privacy.description")}>
<div className="divide-y divide-border">
<ToggleRow
label={t("account:privacy.mutual")}
hint={t("account:privacy.mutualHint")}
checked={!settings.sharedConnectionsHidden}
onChange={setShared}
disabled={busy}
/>
<ToggleRow
label={t("account:privacy.discord")}
hint={t("account:privacy.discordHint")}
checked={!settings.discordFriendsHidden}
onChange={setDiscord}
disabled={busy}
/>
</div>
<Notice error={error} />
</Section>
);
}
@@ -0,0 +1,190 @@
import { useState } from "react";
import { AlertTriangle, Eye, KeyRound, ShieldCheck } from "lucide-react";
import type { RecoveryCode } from "../../../../../shared/types/settings";
import { api } from "../../../lib/api";
import { useI18n } from "../../../lib/i18n";
import { Badge, Button, Field, Modal } from "../../../components/ui";
import { useStepUp } from "../../auth/useStepUp";
import { TwoFactorPrompt } from "../../auth/TwoFactorPrompt";
import { Notice, Section, useAsync, type SectionProps } from "../ui";
export function TwoFactorSection({ settings, onChange }: SectionProps) {
const { t } = useI18n();
const [pending, setPending] = useState<{
secret: string;
qrCodeDataUrl: string;
} | null>(null);
const [code, setCode] = useState("");
const [codes, setCodes] = useState<RecoveryCode[] | null>(null);
const setup = useAsync();
const verify = useAsync();
const [confirmDisable, setConfirmDisable] = useState(false);
const [disabledOk, setDisabledOk] = useState(false);
const stepUp = useStepUp();
async function begin() {
const res = await setup.run(api.settings.enable2fa());
if (res) setPending(res);
}
async function confirm() {
const res = await verify.run(api.settings.verify2fa(code.trim()));
if (!res) return;
if (!res.verified) {
verify.fail(t("account:twoFactor.incorrectCode"));
return;
}
setPending(null);
setCode("");
const fresh = await api.settings.get().catch(() => null);
if (fresh) onChange(fresh);
}
async function turnOff() {
setConfirmDisable(false);
await stepUp.run(async () => {
const next = await api.settings.disable2fa();
onChange(next);
setCodes(null);
setDisabledOk(true);
});
}
async function showCodes() {
setDisabledOk(false);
await stepUp.run(async () => {
setCodes(await api.settings.recoveryCodes());
});
}
function download() {
if (!codes) return;
const body = codes.map((c) => c.code).join("\n");
const url = URL.createObjectURL(new Blob([body], { type: "text/plain" }));
const a = document.createElement("a");
a.href = url;
a.download = "vrchat-recovery-codes.txt";
a.click();
URL.revokeObjectURL(url);
}
return (
<Section
title={t("account:twoFactor.title")}
icon={<ShieldCheck size={16} />}
description={t("account:twoFactor.description")}
>
<div className="flex items-center gap-2">
{settings.twoFactorEnabled ? (
<Badge tone="success">{t("account:twoFactor.enabled")}</Badge>
) : (
<Badge tone="warn">{t("account:twoFactor.disabled")}</Badge>
)}
{settings.twoFactorEnabledDate ? (
<span className="text-[12px] text-faint">
{t("account:twoFactor.since", {
date: new Date(settings.twoFactorEnabledDate).toLocaleDateString(),
})}
</span>
) : null}
</div>
{settings.twoFactorEnabled ? (
<div className="mt-4 flex flex-col gap-4">
<div className="flex flex-wrap gap-2.5">
<Button variant="ghost" onClick={showCodes} loading={stepUp.busy && !confirmDisable}>
<Eye size={14} />
{t("account:twoFactor.showCodes")}
</Button>
<Button variant="danger" onClick={() => setConfirmDisable(true)}>
{t("account:twoFactor.disable")}
</Button>
</div>
{codes ? (
<div className="rounded-lg border border-border bg-surface-2 p-4">
<div className="mb-2.5 flex items-center justify-between">
<span className="text-[12px] font-semibold uppercase tracking-wide text-faint">
{t("account:twoFactor.recoveryCodes")}
</span>
<Button variant="ghost" onClick={download}>
{t("account:twoFactor.download")}
</Button>
</div>
<ul className="grid grid-cols-2 gap-x-6 gap-y-1.5 font-mono text-[13px] sm:grid-cols-3">
{codes.map((c) => (
<li
key={c.code}
className={c.used ? "text-faint line-through" : "text-text"}
title={c.used ? t("account:twoFactor.alreadyUsed") : undefined}
>
{c.code}
</li>
))}
</ul>
<p className="mt-3 text-[12px] text-muted">{t("account:twoFactor.storeSafely")}</p>
</div>
) : null}
<Notice
error={stepUp.prompt ? null : stepUp.error}
ok={disabledOk ? t("account:twoFactor.disabledOk") : null}
/>
<Modal
open={confirmDisable}
onClose={() => setConfirmDisable(false)}
title={t("account:twoFactor.disableModal.title")}
icon={<AlertTriangle size={16} />}
danger
confirmLabel={t("account:twoFactor.disableModal.confirm")}
onConfirm={turnOff}
confirmLoading={stepUp.busy}
>
{t("account:twoFactor.disableModal.body")}
</Modal>
<TwoFactorPrompt
open={!!stepUp.prompt}
methods={stepUp.prompt?.methods ?? ["totp"]}
busy={stepUp.busy}
error={stepUp.error}
onSubmit={stepUp.submit}
onClose={stepUp.cancel}
/>
</div>
) : pending ? (
<div className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-start">
<img
src={pending.qrCodeDataUrl}
alt={t("account:twoFactor.qrAlt")}
className="size-40 shrink-0 rounded-lg border border-border bg-surface p-2"
/>
<div className="min-w-0 flex-1">
<p className="text-[13px] text-muted">{t("account:twoFactor.scanHint")}</p>
<code className="mt-1.5 block break-all rounded-md border border-border bg-surface-2 px-2.5 py-1.5 font-mono text-[12.5px]">
{pending.secret}
</code>
<div className="mt-3 flex items-end gap-2.5">
<Field
label={t("account:twoFactor.codeField")}
inputMode="numeric"
placeholder="123456"
value={code}
onChange={(e) => setCode(e.target.value)}
/>
<Button onClick={confirm} loading={verify.busy} disabled={code.trim().length < 6}>
{t("account:twoFactor.verifyEnable")}
</Button>
</div>
<Notice error={verify.error} />
</div>
</div>
) : (
<div className="mt-4">
<Button onClick={begin} loading={setup.busy}>
<KeyRound size={14} />
{t("account:twoFactor.enable")}
</Button>
<Notice error={setup.error} />
</div>
)}
</Section>
);
}
@@ -0,0 +1,39 @@
import { useState } from "react";
import { api } from "../../../lib/api";
import { useI18n } from "../../../lib/i18n";
import { Button } from "../../../components/ui";
import { Notice, Section, useAsync } from "../ui";
export function UserDataSection() {
const { t } = useI18n();
const { busy, error, ok, run } = useAsync();
const [armed, setArmed] = useState(false);
async function reset() {
await run(api.settings.resetUserData(), {
okMsg: t("account:userData.done"),
});
setArmed(false);
}
return (
<Section title={t("account:userData.title")} description={t("account:userData.description")}>
{armed ? (
<div className="flex flex-wrap items-center gap-2.5">
<span className="text-[13px] text-muted">{t("account:userData.confirmPrompt")}</span>
<Button variant="danger" onClick={reset} loading={busy}>
{t("account:userData.confirm")}
</Button>
<Button variant="ghost" onClick={() => setArmed(false)} disabled={busy}>
{t("account:common.cancel")}
</Button>
</div>
) : (
<Button variant="ghost" onClick={() => setArmed(true)}>
{t("account:userData.reset")}
</Button>
)}
<Notice error={error} ok={ok} />
</Section>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { useState, type ReactNode } from "react";
import { Check, ExternalLink, X } from "lucide-react";
import type { AccountSettings } from "../../../../shared/types/settings";
import { errorMessage } from "../../lib/api";
import { useI18n } from "../../lib/i18n";
import { Toggle } from "../../components/ui";
export type TFunc = ReturnType<typeof useI18n>["t"];
export type SectionProps = {
settings: AccountSettings;
onChange: (s: AccountSettings) => void;
};
export const WEBSITE_ACCOUNT = "https://vrchat.com/home/profile";
export function Section({
title,
description,
icon,
danger,
children,
}: {
title: string;
description?: ReactNode;
icon?: ReactNode;
danger?: boolean;
children: ReactNode;
}) {
return (
<section
className={`rounded-DEFAULT border px-6 py-[22px] shadow-[var(--shadow-1)] ${
danger
? "border-[color-mix(in_srgb,var(--danger)_35%,var(--border))] bg-[color-mix(in_srgb,var(--danger)_6%,var(--surface-2))]"
: "border-border bg-surface-2"
}`}
>
<div className="mb-4">
<h2 className="flex items-center gap-2 text-[15px] font-bold">
{icon ? <span className="text-muted">{icon}</span> : null}
{title}
</h2>
{description ? <p className="mt-1 text-[13px] text-muted">{description}</p> : null}
</div>
{children}
</section>
);
}
export function useAsync() {
const { t } = useI18n();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [ok, setOk] = useState<string | null>(null);
async function run<T>(p: Promise<T>, opts?: { onOk?: (v: T) => void; okMsg?: string }) {
setBusy(true);
setError(null);
setOk(null);
try {
const v = await p;
opts?.onOk?.(v);
if (opts?.okMsg) setOk(opts.okMsg);
return v;
} catch (e) {
setError(errorMessage(e, t("account:common.genericError")));
return undefined;
} finally {
setBusy(false);
}
}
return {
busy,
error,
ok,
run,
fail: (msg: string) => setError(msg),
clear: () => (setError(null), setOk(null)),
};
}
export function Notice({ error, ok }: { error?: string | null; ok?: string | null }) {
if (error)
return (
<p className="mt-3 flex items-center gap-1.5 text-[12.5px] text-[var(--danger)]">
<X size={14} /> {error}
</p>
);
if (ok)
return (
<p className="mt-3 flex items-center gap-1.5 text-[12.5px] text-[var(--status-active)]">
<Check size={14} /> {ok}
</p>
);
return null;
}
export function ToggleRow({
label,
hint,
checked,
onChange,
disabled,
}: {
label: string;
hint?: ReactNode;
checked: boolean;
onChange: (v: boolean) => void;
disabled?: boolean;
}) {
return (
<div className="flex items-center gap-4 py-2.5">
<div className="min-w-0 flex-1">
<div className="text-[13.5px] font-semibold">{label}</div>
{hint ? <div className="mt-0.5 text-[12px] text-muted">{hint}</div> : null}
</div>
<Toggle checked={checked} onChange={onChange} disabled={disabled} />
</div>
);
}
export function ExternalButton({ href, children }: { href: string; children: ReactNode }) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-3.5 py-2 text-[13px] font-semibold text-text transition-colors hover:border-accent hover:text-accent"
>
{children}
<ExternalLink size={13} />
</a>
);
}
export function daysSince(iso?: string): number | null {
if (!iso) return null;
const ms = Date.now() - new Date(iso).getTime();
if (Number.isNaN(ms)) return null;
return Math.floor(ms / 86_400_000);
}
export function addDays(iso: string, days: number): string {
const d = new Date(iso);
d.setDate(d.getDate() + days);
return d.toISOString();
}
export function lastChangedLabel(t: TFunc, iso?: string): string | null {
const days = daysSince(iso);
if (days === null) return null;
if (days <= 0) return t("account:displayName.lastChanged.today");
if (days === 1) return t("account:displayName.lastChanged.yesterday");
return t("account:displayName.lastChanged.daysAgo", { count: days });
}
@@ -0,0 +1,43 @@
import { useCallback, useEffect, useState } from "react";
import type { AccountSettings } from "../../../../shared/types/settings";
import { api, errorMessage } from "../../lib/api";
import { useAuth } from "../auth/AuthContext";
type State =
| { status: "loading" }
| { status: "error"; message: string }
| { status: "ready"; settings: AccountSettings };
export function useAccountSettings(): {
state: State;
reload: () => void;
set: (s: AccountSettings) => void;
} {
const { status } = useAuth();
const activeId = status.state === "authenticated" ? status.user.id : null;
const [state, setState] = useState<State>({ status: "loading" });
const reload = useCallback(() => {
setState({ status: "loading" });
api.settings
.get()
.then((settings) => setState({ status: "ready", settings }))
.catch((err) =>
setState({
status: "error",
message: errorMessage(err, "Failed to load settings."),
}),
);
}, []);
useEffect(() => {
reload();
}, [reload, activeId]);
const set = useCallback(
(settings: AccountSettings) => setState({ status: "ready", settings }),
[],
);
return { state, reload, set };
}
@@ -0,0 +1,217 @@
import { useEffect, useState } from "react";
import { Check, ChevronDown, LogOut, Plus, Users, X } from "lucide-react";
import { useAuth } from "./AuthContext";
import { Avatar, StatusDot } from "../../components/ui";
import { useSelf } from "../../store/social";
import { statusMeta } from "../../lib/vrchat";
import { api } from "../../lib/api";
import type { UserStatus } from "../../../../shared/types/user";
const STATUS_CHOICES: UserStatus[] = ["join me", "active", "ask me", "busy"];
const MENU_ROW =
"flex items-center gap-2.5 rounded-sm px-2.5 py-2 text-[12.5px] font-semibold text-muted text-left transition-[background,color] duration-[var(--dur)] ease-[var(--ease)] hover:bg-surface-2 hover:text-text";
export function AccountSwitcher() {
const { accounts, switchAccount, removeAccount, beginAddAccount, logout } = useAuth();
const self = useSelf();
const [open, setOpen] = useState(false);
const [showAccounts, setShowAccounts] = useState(false);
const active = accounts.accounts.find((a) => a.id === accounts.activeId);
const others = accounts.accounts.filter((a) => a.id !== accounts.activeId);
const status = self?.status ?? "offline";
function close() {
setOpen(false);
setShowAccounts(false);
}
return (
<div className="relative">
{open ? <div className="fixed inset-0 z-40" onClick={close} /> : null}
<button
className={`acct__current group/trigger flex w-full items-center gap-2.5 rounded-DEFAULT border bg-surface-2 py-1.5 pl-1.5 pr-2.5 transition-[background,border-color,box-shadow] duration-[var(--dur)] ease-[var(--ease)] hover:border-border-strong hover:bg-surface-hover ${
open
? "border-accent bg-surface-hover shadow-[0_0_0_3px_var(--accent-weak)]"
: "border-border"
}`}
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
<span className="acct__current relative shrink-0 leading-[0]">
<Avatar src={active?.userIcon} name={active?.displayName} size={32} />
<StatusDot
color={statusMeta[status].color}
ring="var(--surface-2)"
title={statusMeta[status].label}
className="absolute -bottom-px -right-px transition-[border-color] duration-[var(--dur)] group-hover/trigger:border-[var(--surface-hover)] group-aria-expanded/trigger:border-[var(--surface-hover)]"
/>
</span>
<span className="acct__current-text flex min-w-0 flex-1 flex-col">
<span className="truncate text-left text-[13.5px] font-semibold leading-[1.25]">
{active?.displayName ?? "Account"}
</span>
<span className="truncate text-left text-[11px] font-medium text-faint">
{self?.statusDescription || statusMeta[status].label}
</span>
</span>
<span
className={`acct__chevron shrink-0 text-faint transition-transform duration-[var(--dur)] ease-[var(--ease)] ${
open ? "rotate-180" : ""
}`}
aria-hidden
>
<ChevronDown size={16} />
</span>
</button>
{open ? (
<div className="animate-rise absolute bottom-[calc(100%+8px)] left-0 z-50 flex w-[300px] origin-bottom-left flex-col rounded-DEFAULT border border-border bg-surface p-2 shadow-[var(--shadow-2)]">
<StatusPicker />
{others.length && showAccounts ? (
<>
<Separator />
<div className="flex flex-col gap-px">
{others.map((a) => (
<div
key={a.id}
className="group/row flex items-center rounded-sm transition-colors hover:bg-surface-2"
>
<button
className="flex min-w-0 flex-1 items-center gap-2.5 px-2 py-[7px]"
onClick={() => {
void switchAccount(a.id);
close();
}}
>
<Avatar src={a.userIcon} name={a.displayName} size={28} />
<span className="truncate text-[13px] font-semibold">{a.displayName}</span>
</button>
<button
className="grid w-[30px] shrink-0 self-stretch place-items-center rounded-sm text-faint opacity-0 transition-[opacity,color,background] duration-[var(--dur)] ease-[var(--ease)] hover:bg-[color-mix(in_srgb,var(--danger)_12%,transparent)] hover:text-danger group-hover/row:opacity-100"
title="Remove account"
aria-label={`Remove ${a.displayName}`}
onClick={() => void removeAccount(a.id)}
>
<X size={15} />
</button>
</div>
))}
</div>
</>
) : null}
<Separator />
<div className="flex flex-col gap-px">
{others.length ? (
<button
className={`${MENU_ROW} ${showAccounts ? "text-text" : ""}`}
onClick={() => setShowAccounts((v) => !v)}
>
<Users size={15} />
Switch account
<span className="ml-auto min-w-[18px] rounded-full bg-surface-hover px-1.5 py-px text-center text-[11px] font-bold text-faint">
{accounts.accounts.length}
</span>
</button>
) : null}
<button
className={MENU_ROW}
onClick={() => {
beginAddAccount();
close();
}}
>
<Plus size={15} />
Add account
</button>
<button
className={`${MENU_ROW} text-danger hover:bg-[color-mix(in_srgb,var(--danger)_12%,transparent)] hover:text-danger`}
onClick={() => {
void logout();
close();
}}
>
<LogOut size={15} />
Sign out
</button>
</div>
</div>
) : null}
</div>
);
}
function Separator() {
return <div className="mx-0.5 my-1.5 h-px bg-border" />;
}
function StatusPicker() {
const self = useSelf();
const current = self?.status ?? "offline";
const [desc, setDesc] = useState(self?.statusDescription ?? "");
const [busy, setBusy] = useState(false);
useEffect(() => {
setDesc(self?.statusDescription ?? "");
}, [self?.id, self?.statusDescription]);
async function apply(status: UserStatus, description: string) {
setBusy(true);
try {
await api.settings.setStatus(status, description.trim());
} catch {
} finally {
setBusy(false);
}
}
const dirty = desc.trim() !== (self?.statusDescription ?? "");
return (
<div className="flex flex-col gap-1.5">
<div className="flex flex-col gap-px">
{STATUS_CHOICES.map((s) => (
<button
key={s}
className={`flex items-center gap-2.5 rounded-sm px-2.5 py-2 text-left text-[12.5px] font-semibold transition-[background,color] duration-[var(--dur)] ease-[var(--ease)] hover:bg-surface-2 ${
current === s ? "text-accent" : "text-text"
}`}
onClick={() => apply(s, desc)}
disabled={busy}
>
<StatusDot color={statusMeta[s].color} size={9} />
<span className="min-w-0 flex-1">{statusMeta[s].label}</span>
{current === s ? <Check size={15} className="shrink-0 text-accent" /> : null}
</button>
))}
</div>
<form
className="flex gap-1.5"
onSubmit={(e) => {
e.preventDefault();
if (current !== "offline" && dirty) void apply(current, desc);
}}
>
<input
className="min-w-0 flex-1 rounded-sm border border-border bg-surface-2 px-[11px] py-2 text-[12.5px] text-text outline-none transition-[border-color] duration-[var(--dur)] ease-[var(--ease)] focus:border-accent"
placeholder="Set a custom status…"
value={desc}
maxLength={32}
onChange={(e) => setDesc(e.target.value)}
/>
<button
type="submit"
className="grid w-[34px] shrink-0 place-items-center rounded-sm bg-accent text-on-accent transition-[filter,opacity] duration-[var(--dur)] ease-[var(--ease)] hover:not-disabled:brightness-105 disabled:cursor-default disabled:opacity-40"
aria-label="Save status"
disabled={busy || !dirty || current === "offline"}
>
<Check size={15} />
</button>
</form>
</div>
);
}
@@ -0,0 +1,143 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { AccountsState, AuthStatus, TwoFactorMethod } from "../../../../shared/types/auth";
import { api, events } from "../../lib/api";
interface AuthContextValue {
status: AuthStatus;
accounts: AccountsState;
loading: boolean;
adding: boolean;
login: (username: string, password: string) => Promise<void>;
verify2fa: (method: TwoFactorMethod, code: string) => Promise<void>;
logout: () => Promise<void>;
switchAccount: (id: string) => Promise<void>;
removeAccount: (id: string) => Promise<void>;
beginAddAccount: () => void;
cancelAddAccount: () => void;
}
const AuthContext = createContext<AuthContextValue | null>(null);
const EMPTY: AccountsState = { accounts: [], activeId: null };
export function AuthProvider({ children }: { children: ReactNode }) {
const [status, setStatus] = useState<AuthStatus>({ state: "unauthenticated" });
const [accounts, setAccounts] = useState<AccountsState>(EMPTY);
const [loading, setLoading] = useState(true);
const [adding, setAdding] = useState(false);
const refreshAccounts = useCallback(() => {
api.accounts
.list()
.then(setAccounts)
.catch(() => setAccounts(EMPTY));
}, []);
useEffect(() => {
api.auth
.status()
.then(setStatus)
.catch(() => setStatus({ state: "unauthenticated" }))
.finally(() => setLoading(false));
refreshAccounts();
const offAuth = events.on("auth:changed", setStatus);
const offAcc = events.on("accounts:changed", setAccounts);
return () => {
offAuth();
offAcc();
};
}, [refreshAccounts]);
const login = useCallback(
async (username: string, password: string) => {
const next = await api.auth.login(username, password);
setStatus(next);
if (next.state === "authenticated") {
setAdding(false);
refreshAccounts();
}
},
[refreshAccounts],
);
const verify2fa = useCallback(
async (method: TwoFactorMethod, code: string) => {
const next = await api.auth.verify2fa(method, code);
setStatus(next);
if (next.state === "authenticated") {
setAdding(false);
refreshAccounts();
}
},
[refreshAccounts],
);
const logout = useCallback(async () => {
await api.auth.logout();
const next = await api.auth.status();
setStatus(next);
refreshAccounts();
}, [refreshAccounts]);
const switchAccount = useCallback(
async (id: string) => {
setStatus(await api.accounts.switch(id));
setAdding(false);
refreshAccounts();
},
[refreshAccounts],
);
const removeAccount = useCallback(async (id: string) => {
setAccounts(await api.accounts.remove(id));
setStatus(await api.auth.status());
}, []);
const beginAddAccount = useCallback(() => setAdding(true), []);
const cancelAddAccount = useCallback(() => setAdding(false), []);
const value = useMemo(
() => ({
status,
accounts,
loading,
adding,
login,
verify2fa,
logout,
switchAccount,
removeAccount,
beginAddAccount,
cancelAddAccount,
}),
[
status,
accounts,
loading,
adding,
login,
verify2fa,
logout,
switchAccount,
removeAccount,
beginAddAccount,
cancelAddAccount,
],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within <AuthProvider>");
return ctx;
}
@@ -0,0 +1,195 @@
import { useState, type FormEvent } from "react";
import { ArrowLeft, CircleDot } from "lucide-react";
import type { TwoFactorMethod } from "../../../../shared/types/auth";
import { ApiException } from "../../lib/api";
import { useAuth } from "./AuthContext";
import { Banner, Button, Field } from "../../components/ui";
import { useI18n } from "../../lib/i18n";
export function LoginScreen() {
const { status, login, verify2fa, adding, cancelAddAccount, accounts } = useAuth();
const { t } = useI18n();
const awaiting2fa = status.state === "awaiting2fa";
const canGoBack = adding && accounts.accounts.length > 0;
return (
<div className="relative grid h-full place-items-center p-6">
<div className="titlebar absolute left-0 top-0" />
<main className="animate-rise relative w-full max-w-[380px] rounded-lg border border-border bg-surface px-[30px] pb-6 pt-8 shadow-[var(--shadow-2)]">
{canGoBack ? (
<button
className="absolute left-4 top-4 inline-flex items-center gap-1 rounded-sm px-2 py-[5px] text-[13px] text-muted transition-[color,background] duration-[var(--dur)] ease-[var(--ease)] hover:bg-surface-2 hover:text-text"
onClick={cancelAddAccount}
>
<ArrowLeft size={15} /> {t("auth:back")}
</button>
) : null}
<header className="mb-[26px] flex flex-col items-center gap-1 text-center">
<div className="mb-1.5 grid size-[34px] place-items-center text-accent" aria-hidden>
<CircleDot size={26} />
</div>
<h1 className="text-[21px] font-bold tracking-[-0.2px]">VRC Circle</h1>
<p className="text-[13.5px] text-muted">
{adding ? t("auth:addAccount") : t("auth:tagline")}
</p>
</header>
{awaiting2fa ? (
<TwoFactorForm methods={status.methods} onSubmit={verify2fa} />
) : (
<CredentialForm onSubmit={login} />
)}
<footer className="mt-[18px] text-center text-[11.5px] leading-normal text-faint">
{t("auth:disclaimer")}
</footer>
</main>
</div>
);
}
function CredentialForm({
onSubmit,
}: {
onSubmit: (username: string, password: string) => Promise<void>;
}) {
const { t } = useI18n();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
await onSubmit(username.trim(), password);
} catch (err) {
setError(messageFor(err, t));
} finally {
setBusy(false);
}
}
return (
<form className="flex flex-col gap-3.5" onSubmit={submit}>
<Field
label={t("auth:username")}
placeholder={t("auth:usernamePlaceholder")}
autoFocus
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
required
/>
<Field
label={t("auth:password")}
type="password"
placeholder="••••••••••"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{error ? <Banner>{error}</Banner> : null}
<Button type="submit" block loading={busy} disabled={!username || !password}>
{t("auth:signIn")}
</Button>
</form>
);
}
function TwoFactorForm({
methods,
onSubmit,
}: {
methods: TwoFactorMethod[];
onSubmit: (method: TwoFactorMethod, code: string) => Promise<void>;
}) {
const { t } = useI18n();
const [method, setMethod] = useState<TwoFactorMethod>(methods[0] ?? "totp");
const [code, setCode] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
await onSubmit(method, code.trim());
} catch (err) {
setError(messageFor(err, t));
} finally {
setBusy(false);
}
}
return (
<form className="flex flex-col gap-3.5" onSubmit={submit}>
<p className="mb-0.5 text-center text-[13.5px] text-muted">
{t("auth:twoFactorPrompt", {
source: t(
method === "emailOtp" ? "auth:twoFactorEmailSource" : "auth:twoFactorAppSource",
),
})}
</p>
{methods.length > 1 ? (
<div className="flex gap-1.5 rounded-lg bg-surface-2 p-1" role="tablist">
{methods.map((m) => (
<button
key={m}
type="button"
role="tab"
aria-selected={m === method}
className={`flex-1 rounded-sm p-2 text-[13px] font-semibold transition-[color,background] duration-[var(--dur)] ease-[var(--ease)] ${
m === method ? "bg-surface text-text shadow-[var(--shadow-1)]" : "text-muted"
}`}
onClick={() => setMethod(m)}
>
{m === "emailOtp" ? t("auth:tabEmail") : t("auth:tabAuthenticator")}
</button>
))}
</div>
) : null}
<Field
label={t("auth:verificationCode")}
placeholder="000000"
inputMode="numeric"
autoFocus
autoComplete="one-time-code"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 8))}
required
/>
{error ? <Banner>{error}</Banner> : null}
<Button type="submit" block loading={busy} disabled={code.length < 6}>
{t("auth:verify")}
</Button>
</form>
);
}
type Translate = ReturnType<typeof useI18n>["t"];
function messageFor(err: unknown, t: Translate): string {
if (err instanceof ApiException) {
switch (err.error.code) {
case "unauthorized":
return t("auth:errorUnauthorized");
case "invalid_2fa":
return t("auth:errorInvalid2fa");
case "rate_limited":
return t("auth:errorRateLimited");
case "network":
return t("auth:errorNetwork");
default:
return err.error.message || t("auth:errorGeneric");
}
}
return t("auth:errorGeneric");
}
@@ -0,0 +1,64 @@
import { useState } from "react";
import { ShieldCheck } from "lucide-react";
import { Field, Modal } from "../../components/ui";
type TwoFactorMethod = "totp" | "emailOtp";
export function TwoFactorPrompt({
open,
methods,
busy,
error,
onSubmit,
onClose,
}: {
open: boolean;
methods: TwoFactorMethod[];
busy?: boolean;
error?: string | null;
onSubmit: (method: TwoFactorMethod, code: string) => void;
onClose: () => void;
}) {
const [code, setCode] = useState("");
const method = methods[0] ?? "totp";
const label = method === "emailOtp" ? "Email code" : "Authenticator code";
function submit() {
if (code.trim().length < 6) return;
onSubmit(method, code.trim());
}
return (
<Modal
open={open}
onClose={() => {
setCode("");
onClose();
}}
title="Verify it's you"
icon={<ShieldCheck size={16} />}
confirmLabel="Verify"
onConfirm={submit}
confirmLoading={busy}
confirmDisabled={code.trim().length < 6}
>
<p>
{method === "emailOtp"
? "Enter the code we emailed you to continue."
: "Enter the code from your authenticator app to continue."}
</p>
<div className="mt-3">
<Field
label={label}
inputMode="numeric"
placeholder="123456"
autoFocus
value={code}
onChange={(e) => setCode(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submit()}
/>
</div>
{error ? <p className="mt-2 text-[12.5px] text-danger">{error}</p> : null}
</Modal>
);
}
@@ -0,0 +1,58 @@
import { useState } from "react";
import { ApiException, api, errorMessage } from "../../lib/api";
type TwoFactorMethod = "totp" | "emailOtp";
export function useStepUp() {
const [prompt, setPrompt] = useState<{
methods: TwoFactorMethod[];
retry: () => Promise<void>;
} | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function run(action: () => Promise<void>): Promise<void> {
setBusy(true);
setError(null);
try {
await action();
} catch (e) {
if (e instanceof ApiException && e.error.code === "requires_2fa") {
setPrompt({ methods: e.error.methods ?? ["totp"], retry: action });
return;
}
throw e;
} finally {
setBusy(false);
}
}
async function submit(method: TwoFactorMethod, code: string) {
if (!prompt) return;
setBusy(true);
setError(null);
try {
const { verified } = await api.settings.reverify2fa(method, code);
if (!verified) {
setError("Incorrect code, try again.");
return;
}
const retry = prompt.retry;
setPrompt(null);
await retry();
} catch (e) {
setError(errorMessage(e, "Something went wrong."));
} finally {
setBusy(false);
}
}
return {
run,
prompt,
busy,
error,
submit,
cancel: () => (setPrompt(null), setError(null)),
};
}
@@ -0,0 +1,93 @@
import { useState } from "react";
import { useSocial } from "../../store/social";
import { useWorlds } from "../../store/worlds";
import { useCopied, useDebug } from "./useDebug";
import { Count, TabButton } from "./ui";
import { CacheTab } from "./tabs/CacheTab";
import { ReposTab } from "./tabs/ReposTab";
import { ThumbnailsTab } from "./tabs/ThumbnailsTab";
import { SocialTab } from "./tabs/SocialTab";
import { WorldStorePanel } from "./tabs/WorldsTab";
import { WsTab } from "./tabs/WebSocketTab";
import { LogsTab } from "./tabs/LogsTab";
type Tab = "cache" | "repos" | "thumbnails" | "social" | "worlds" | "ws" | "logs";
export function DebugPanel() {
const { cache, stats, logs, ws, repoStats, invalidate, clear, clearLogs, clearWs } = useDebug();
const [tab, setTab] = useState<Tab>("cache");
const friendCount = useSocial((s) => Object.values(s.users).filter((u) => u.isFriend).length);
const worldCount = useWorlds((s) => Object.keys(s.worlds).length);
const repoCount = repoStats.reduce((sum, r) => sum + r.count, 0);
const [exported, exportSnapshot] = useCopied();
function copyEverything() {
exportSnapshot(
JSON.stringify(
{
exportedAt: new Date().toISOString(),
platform: window.api?.platform,
cacheStats: stats,
repoStats,
counts: { friends: friendCount, worlds: worldCount, ws: ws.length, logs: logs.length },
logs,
ws,
},
null,
2,
),
);
}
return (
<div className="debug flex h-full flex-col gap-4">
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-border bg-surface p-1">
<TabButton active={tab === "cache"} onClick={() => setTab("cache")}>
Cache <Count n={cache.length} />
</TabButton>
<TabButton active={tab === "repos"} onClick={() => setTab("repos")}>
Repositories <Count n={repoCount} />
</TabButton>
<TabButton active={tab === "thumbnails"} onClick={() => setTab("thumbnails")}>
Thumbnails
</TabButton>
<TabButton active={tab === "social"} onClick={() => setTab("social")}>
Social <Count n={friendCount} />
</TabButton>
<TabButton active={tab === "worlds"} onClick={() => setTab("worlds")}>
Worlds <Count n={worldCount} />
</TabButton>
<TabButton active={tab === "ws"} onClick={() => setTab("ws")}>
WebSocket <Count n={ws.length} />
</TabButton>
<TabButton active={tab === "logs"} onClick={() => setTab("logs")}>
Logs <Count n={logs.length} />
</TabButton>
<button
className="ml-auto shrink-0 rounded-md px-2.5 py-1 text-[12px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-accent"
onClick={copyEverything}
title="Copy a full diagnostic snapshot for bug reports"
>
{exported ? "Copied!" : "Export snapshot"}
</button>
</div>
{tab === "cache" ? (
<CacheTab cache={cache} stats={stats} onInvalidate={invalidate} onClear={clear} />
) : tab === "repos" ? (
<ReposTab repos={repoStats} />
) : tab === "thumbnails" ? (
<ThumbnailsTab />
) : tab === "social" ? (
<SocialTab />
) : tab === "worlds" ? (
<WorldStorePanel />
) : tab === "ws" ? (
<WsTab ws={ws} onClear={clearWs} />
) : (
<LogsTab logs={logs} onClear={clearLogs} />
)}
</div>
);
}
@@ -0,0 +1,9 @@
import { DebugPanel } from "./DebugPanel";
export function DebugWindow() {
return (
<div className="h-screen overflow-hidden bg-bg p-5 text-text">
<DebugPanel />
</div>
);
}
@@ -0,0 +1,232 @@
import { useMemo, useState } from "react";
import { ChevronDown, ChevronRight, X } from "lucide-react";
import type { CacheEntryInfo, CacheStats } from "../../../../../shared/types/debug";
import { Badge, Button, Panel, Stat } from "../../../components/ui";
import { useCopied, useNow } from "../useDebug";
import {
Chip,
Empty,
Meta,
SearchInput,
cacheStatus,
formatBytes,
formatDuration,
formatTime,
statusColor,
statusTone,
} from "../ui";
type CacheFilter = "all" | "fresh" | "stale" | "expired";
const CACHE_GROUPS = ["Worlds", "Users", "Friends", "Search", "Other"] as const;
function cacheGroupOf(key: string): string {
if (key.startsWith("user:worlds:")) return "Worlds";
if (key.startsWith("user:search:")) return "Search";
if (key === "friends") return "Friends";
if (key === "user:me" || key.startsWith("user:")) return "Users";
return "Other";
}
export function CacheTab({
cache,
stats,
onInvalidate,
onClear,
}: {
cache: CacheEntryInfo[];
stats: CacheStats | null;
onInvalidate: (key: string) => void;
onClear: () => void;
}) {
const now = useNow();
const [filter, setFilter] = useState<CacheFilter>("all");
const [query, setQuery] = useState("");
const hitRate =
stats && stats.hits + stats.misses > 0
? Math.round((stats.hits / (stats.hits + stats.misses)) * 100)
: null;
const shown = useMemo(() => {
const q = query.trim().toLowerCase();
return [...cache]
.filter((e) => (filter === "all" ? true : cacheStatus(e, now) === filter))
.filter((e) => (q ? e.key.toLowerCase().includes(q) : true))
.sort((a, b) => a.key.localeCompare(b.key));
}, [cache, filter, query, now]);
const groups = useMemo(() => {
const by = new Map<string, CacheEntryInfo[]>();
for (const e of shown) {
const g = cacheGroupOf(e.key);
(by.get(g) ?? by.set(g, []).get(g)!).push(e);
}
const order = (g: string) => {
const i = CACHE_GROUPS.indexOf(g as (typeof CACHE_GROUPS)[number]);
return i === -1 ? CACHE_GROUPS.length : i;
};
return [...by.entries()].sort((a, b) => order(a[0]) - order(b[0]));
}, [shown]);
return (
<div className="flex min-h-0 flex-col gap-4">
{stats ? (
<Panel
title="Cache stats"
meta={stats.persisted ? `persisted · ${formatBytes(stats.totalSize)}` : "in-memory"}
>
<div className="grid grid-cols-2 gap-2 p-4 sm:grid-cols-4 lg:grid-cols-6">
<Stat label="Entries" value={stats.entries} />
<Stat label="Fresh" value={stats.fresh} tone="var(--status-active)" />
<Stat label="Stale" value={stats.stale} tone="var(--status-ask)" />
<Stat label="Expired" value={stats.expired} tone="var(--danger)" />
<Stat label="In-flight" value={stats.inflight} />
<Stat
label="Hit rate"
value={hitRate === null ? "—" : `${hitRate}%`}
tone="var(--accent)"
/>
<Stat label="Hits" value={stats.hits} />
<Stat label="Misses" value={stats.misses} />
<Stat label="Sets" value={stats.sets} />
<Stat label="Patches" value={stats.patches} />
<Stat label="Revalidated" value={stats.revalidations} />
<Stat label="Invalidated" value={stats.invalidations} />
</div>
</Panel>
) : null}
<Panel
title="Cache entries"
meta={`${shown.length}/${cache.length} · pull / TTL`}
action={
<Button variant="ghost" onClick={onClear} disabled={cache.length === 0}>
Clear all
</Button>
}
className="min-h-0 flex-1"
>
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-4 py-2.5">
<SearchInput value={query} onChange={setQuery} placeholder="Filter keys…" />
<div className="flex gap-1">
{(["all", "fresh", "stale", "expired"] as CacheFilter[]).map((f) => (
<Chip key={f} active={filter === f} onClick={() => setFilter(f)}>
{f}
</Chip>
))}
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-auto">
{shown.length === 0 ? (
<Empty>{cache.length === 0 ? "Cache is empty." : "No entries match."}</Empty>
) : (
groups.map(([group, entries]) => (
<div key={group}>
<div className="sticky top-0 z-10 flex items-center gap-2 bg-surface-2 px-4 py-1.5 text-[10.5px] font-semibold uppercase tracking-wide text-faint">
{group}
<span className="text-faint/70">{entries.length}</span>
</div>
{entries.map((e) => (
<CacheRow
key={e.key}
entry={e}
now={now}
onInvalidate={() => onInvalidate(e.key)}
/>
))}
</div>
))
)}
</div>
</Panel>
</div>
);
}
function CacheRow({
entry,
now,
onInvalidate,
}: {
entry: CacheEntryInfo;
now: number;
onInvalidate: () => void;
}) {
const [open, setOpen] = useState(false);
const [copied, copy] = useCopied();
const remaining = entry.expiresAt - now;
const total = entry.expiresAt - entry.createdAt;
const pct = total > 0 ? Math.max(0, Math.min(100, (remaining / total) * 100)) : 0;
const age = Math.max(0, Math.round((now - entry.createdAt) / 1000));
const status = cacheStatus(entry, now);
return (
<div className="border-b border-border last:border-b-0">
<div className="flex items-center gap-2 px-4 py-2.5">
<button
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
onClick={() => setOpen((v) => !v)}
>
<span className="flex w-3 shrink-0 justify-center text-faint">
{open ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
</span>
<Badge tone={statusTone[status]}>{status}</Badge>
<code className="truncate font-mono text-xs text-text">{entry.key}</code>
</button>
<span className="hidden shrink-0 items-center gap-3 text-[11px] tabular-nums text-faint sm:flex">
<span title="cache hits">{entry.hits} hits</span>
<span title="value size">{formatBytes(entry.size)}</span>
<span title="age since cached">{formatDuration(age)} old</span>
<span className="w-12 text-right" title="time until stale">
{status === "fresh" ? formatDuration(Math.round(remaining / 1000)) : status}
</span>
</span>
<button
className="shrink-0 rounded px-1.5 leading-none text-faint transition-colors hover:text-danger"
title="Invalidate"
onClick={onInvalidate}
>
<X size={16} />
</button>
</div>
<div className="px-4 pb-1.5">
<div className="h-1 overflow-hidden rounded-full bg-surface-2">
<div
className="h-full rounded-full transition-[width] duration-700 ease-linear"
style={{ width: `${pct}%`, background: statusColor[status] }}
/>
</div>
</div>
{open ? (
<div className="px-4 pb-3">
<dl className="mb-2 grid grid-cols-2 gap-x-4 gap-y-1 text-[11px] sm:grid-cols-4">
<Meta label="Created" value={formatTime(entry.createdAt)} />
<Meta label="Stale at" value={formatTime(entry.expiresAt)} />
<Meta label="Hard expiry" value={formatTime(entry.hardExpiresAt)} />
<Meta
label="Last read"
value={entry.lastAccess ? formatTime(entry.lastAccess) : "never"}
/>
</dl>
<div className="relative">
<button
className="absolute right-2 top-2 rounded border border-border bg-surface px-2 py-0.5 text-[10.5px] font-semibold text-muted transition-colors hover:text-accent"
onClick={() => copy(JSON.stringify(entry.value, null, 2))}
>
{copied ? "Copied!" : "Copy"}
</button>
<pre className="max-h-72 overflow-auto rounded-md border border-border bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-muted">
{JSON.stringify(entry.value, null, 2)}
</pre>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,74 @@
import { useMemo, useState } from "react";
import type { LogEntry, LogLevel } from "../../../../../shared/types/debug";
import { Button, Panel } from "../../../components/ui";
import { Chip, Empty, SearchInput, formatTime } from "../ui";
const LEVELS: LogLevel[] = ["debug", "info", "warn", "error"];
const levelTone: Record<LogLevel, string> = {
debug: "var(--faint)",
info: "var(--muted)",
warn: "var(--status-ask)",
error: "var(--danger)",
};
export function LogsTab({ logs, onClear }: { logs: LogEntry[]; onClear: () => void }) {
const [min, setMin] = useState<LogLevel>("debug");
const [query, setQuery] = useState("");
const minIdx = LEVELS.indexOf(min);
const shown = useMemo(() => {
const q = query.trim().toLowerCase();
return logs.filter(
(l) =>
LEVELS.indexOf(l.level) >= minIdx &&
(q ? `${l.scope} ${l.message}`.toLowerCase().includes(q) : true),
);
}, [logs, minIdx, query]);
return (
<Panel
title="Logs"
meta={`${shown.length}/${logs.length}`}
className="min-h-0 flex-1"
action={
<Button variant="ghost" onClick={onClear} disabled={logs.length === 0}>
Clear
</Button>
}
>
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-4 py-2.5">
<SearchInput value={query} onChange={setQuery} placeholder="Search logs…" />
<div className="flex gap-1">
{LEVELS.map((l) => (
<Chip key={l} active={min === l} onClick={() => setMin(l)}>
{l}
</Chip>
))}
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col-reverse overflow-auto p-2 font-mono text-[11.5px] leading-relaxed">
{shown.length === 0 ? (
<Empty>No logs match.</Empty>
) : (
shown
.slice()
.reverse()
.map((l) => (
<div key={l.id} className="flex gap-2 rounded px-2 py-0.5 hover:bg-surface-2">
<span className="shrink-0 tabular-nums text-faint">{formatTime(l.ts)}</span>
<span
className="w-10 shrink-0 font-semibold uppercase"
style={{ color: levelTone[l.level] }}
>
{l.level}
</span>
<span className="shrink-0 text-accent">{l.scope}</span>
<span className="min-w-0 break-words text-muted">{l.message}</span>
</div>
))
)}
</div>
</Panel>
);
}
@@ -0,0 +1,273 @@
import { useEffect, useMemo, useState } from "react";
import { ChevronDown, ChevronRight, Trash2 } from "lucide-react";
import type { RepoStats, StoredEntity } from "../../../../../shared/types/repository";
import { Button, Panel, Stat } from "../../../components/ui";
import { api } from "../../../lib/api";
import { useCopied, useNow } from "../useDebug";
import {
Empty,
Meta,
SearchInput,
formatBytes,
formatFieldValue,
relativeAge,
sourceColor,
} from "../ui";
export function ReposTab({ repos }: { repos: RepoStats[] }) {
const now = useNow(2000);
const [selected, setSelected] = useState<string | null>(null);
const totalCount = repos.reduce((s, r) => s + r.count, 0);
const totalSize = repos.reduce((s, r) => s + r.totalSize, 0);
const pending = repos.reduce((s, r) => s + r.pendingWrites, 0);
const dir = repos[0]?.backendFile?.replace(/[^/]+$/, "") ?? null;
if (selected) {
return <RepoInspector name={selected} onBack={() => setSelected(null)} now={now} />;
}
return (
<div className="flex min-h-0 flex-col gap-4">
<Panel title="Entity repositories" meta={`persisted · ${formatBytes(totalSize)}`}>
<div className="grid grid-cols-2 gap-2 p-4 sm:grid-cols-4">
<Stat label="Entities" value={totalCount} />
<Stat label="Size" value={formatBytes(totalSize)} />
<Stat label="Types" value={repos.length} />
<Stat
label="Pending writes"
value={pending}
tone={pending > 0 ? "var(--status-ask)" : undefined}
/>
</div>
{dir ? (
<p className="border-t border-border px-4 py-2 text-[11px] text-faint">Stored at {dir}</p>
) : null}
</Panel>
{repos.length === 0 ? (
<Panel title="By type">
<Empty>No active account repositories load when signed in.</Empty>
</Panel>
) : (
<div className="grid gap-3 sm:grid-cols-3">
{repos.map((r) => (
<RepoCard key={r.name} repo={r} now={now} onInspect={() => setSelected(r.name)} />
))}
</div>
)}
</div>
);
}
function RepoCard({
repo,
now,
onInspect,
}: {
repo: RepoStats;
now: number;
onInspect: () => void;
}) {
const [busy, setBusy] = useState<"flush" | "clear" | null>(null);
async function flush() {
setBusy("flush");
try {
await api.debug.repoFlush(repo.name);
} finally {
setBusy(null);
}
}
async function clear() {
setBusy("clear");
try {
await api.debug.repoClear(repo.name);
} finally {
setBusy(null);
}
}
return (
<Panel title={repo.name} meta={formatBytes(repo.totalSize)}>
<div className="grid grid-cols-2 gap-2 p-3">
<Stat label="Entities" value={repo.count} />
<Stat
label="Pending"
value={repo.pendingWrites}
tone={repo.pendingWrites > 0 ? "var(--status-ask)" : undefined}
/>
</div>
<dl className="grid grid-cols-2 gap-x-3 gap-y-1 px-3 pb-2 text-[11px]">
<Meta
label="Last fetch"
value={repo.newestFetch ? relativeAge(now, repo.newestFetch) : "—"}
/>
<Meta
label="Oldest read"
value={repo.oldestRead ? relativeAge(now, repo.oldestRead) : "—"}
/>
</dl>
<div className="flex items-center gap-1.5 border-t border-border px-3 py-2">
<Button variant="ghost" onClick={onInspect} disabled={repo.count === 0}>
Inspect
</Button>
<Button
variant="ghost"
onClick={flush}
loading={busy === "flush"}
disabled={repo.pendingWrites === 0}
>
Flush
</Button>
<Button
variant="ghost"
onClick={clear}
loading={busy === "clear"}
disabled={repo.count === 0}
>
<Trash2 size={13} /> Clear
</Button>
</div>
</Panel>
);
}
function RepoInspector({ name, onBack, now }: { name: string; onBack: () => void; now: number }) {
const [entities, setEntities] = useState<StoredEntity<{ id: string }>[]>([]);
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(true);
const reload = () => {
setLoading(true);
api.debug
.repoInspect(name)
.then((e) => setEntities(e))
.finally(() => setLoading(false));
};
useEffect(reload, [name]);
const shown = useMemo(() => {
const q = query.trim().toLowerCase();
return [...entities]
.filter((e) => {
if (!q) return true;
const blob = `${e.data.id} ${JSON.stringify(e.data).toLowerCase()}`;
return blob.includes(q);
})
.sort((a, b) => (b.meta.lastFetch ?? 0) - (a.meta.lastFetch ?? 0))
.slice(0, 300);
}, [entities, query]);
return (
<Panel
title={`${name} inspector`}
meta={`${shown.length}/${entities.length} shown`}
className="min-h-0 flex-1"
action={
<div className="flex items-center gap-1.5">
<Button variant="ghost" onClick={reload} loading={loading}>
Refresh
</Button>
<Button variant="ghost" onClick={onBack}>
Back
</Button>
</div>
}
>
<div className="shrink-0 border-b border-border px-4 py-2.5">
<SearchInput value={query} onChange={setQuery} placeholder="Filter by id or any field…" />
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-auto">
{shown.length === 0 ? (
<Empty>{entities.length === 0 ? "Repository is empty." : "No entities match."}</Empty>
) : (
shown.map((e) => <EntityRow key={e.data.id} entity={e} now={now} />)
)}
</div>
</Panel>
);
}
function EntityRow({ entity, now }: { entity: StoredEntity<{ id: string }>; now: number }) {
const [open, setOpen] = useState(false);
const [copied, copy] = useCopied();
const data = entity.data as Record<string, unknown>;
const label = (data.name ?? data.displayName ?? data.id) as string;
const fieldNames = Object.keys(entity.meta.fields).sort();
return (
<div className="border-b border-border last:border-b-0">
<button
className="flex w-full items-center gap-2.5 px-4 py-2 text-left hover:bg-surface-2"
onClick={() => setOpen((v) => !v)}
>
<span className="flex w-3 shrink-0 justify-center text-faint">
{open ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
</span>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-[13px] font-medium">{label}</span>
<code className="truncate font-mono text-[10px] text-faint">{entity.data.id}</code>
</div>
<span className="shrink-0 text-[11px] tabular-nums text-faint">
{fieldNames.length} fields
</span>
<span className="shrink-0 text-[11px] tabular-nums text-faint">
{relativeAge(now, entity.meta.lastFetch)}
</span>
</button>
{open ? (
<div className="px-4 pb-3 pl-[2.4rem]">
<div className="mb-2 overflow-hidden rounded-md border border-border">
<table className="w-full text-[11px]">
<thead className="bg-surface-2 text-faint">
<tr className="text-left">
<th className="px-2 py-1 font-medium">Field</th>
<th className="px-2 py-1 font-medium">Value</th>
<th className="px-2 py-1 font-medium">Source</th>
<th className="px-2 py-1 text-right font-medium">Updated</th>
</tr>
</thead>
<tbody>
{fieldNames.map((f) => {
const meta = entity.meta.fields[f];
return (
<tr key={f} className="border-t border-border/50">
<td className="px-2 py-1 font-mono text-muted">{f}</td>
<td className="max-w-[14rem] truncate px-2 py-1 font-mono text-text">
{formatFieldValue(data[f])}
</td>
<td className="px-2 py-1">
<span style={{ color: sourceColor(meta.src) }}>{meta.src}</span>
</td>
<td className="px-2 py-1 text-right tabular-nums text-faint">
{meta.at ? relativeAge(now, meta.at) : "stale"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<dl className="mb-2 grid grid-cols-2 gap-x-4 gap-y-1 text-[11px] sm:grid-cols-3">
<Meta label="First seen" value={relativeAge(now, entity.meta.firstSeen)} />
<Meta label="Last read" value={relativeAge(now, entity.meta.lastRead)} />
<Meta label="Last fetch" value={relativeAge(now, entity.meta.lastFetch)} />
</dl>
<div className="relative">
<button
className="absolute right-2 top-2 rounded border border-border bg-surface px-2 py-0.5 text-[10.5px] font-semibold text-muted transition-colors hover:text-accent"
onClick={() => copy(JSON.stringify(entity, null, 2))}
>
{copied ? "Copied!" : "Copy"}
</button>
<pre className="max-h-72 overflow-auto rounded-md border border-border bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-muted">
{JSON.stringify(entity.data, null, 2)}
</pre>
</div>
</div>
) : null}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More