mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-14 11:19:26 +00:00
✨ feat: Initial commit
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
Vendored
+302
@@ -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 {}
|
||||
}
|
||||
}
|
||||
Vendored
+32
@@ -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}`,
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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?.();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
}
|
||||
@@ -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).`);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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]>)),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
@@ -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();
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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) };
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user