feat: Initial commit

This commit is contained in:
2026-06-27 23:25:12 +07:00
commit c8317eb02a
189 changed files with 20068 additions and 0 deletions
+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");
}