mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-07-15 14:27:48 +00:00
✨ feat: Initial commit
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
out/
|
||||
dist/
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
.DS_Store
|
||||
.env
|
||||
.env.*
|
||||
@@ -0,0 +1,7 @@
|
||||
out
|
||||
dist
|
||||
node_modules
|
||||
release
|
||||
coverage
|
||||
*.log
|
||||
pnpm-lock.yaml
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
appId: cafe.kirameki.vrc-circle
|
||||
productName: VRC Circle
|
||||
directories:
|
||||
output: dist
|
||||
buildResources: resources
|
||||
files:
|
||||
- out/**
|
||||
- package.json
|
||||
linux:
|
||||
target: [AppImage]
|
||||
category: Network
|
||||
win:
|
||||
target: [nsis]
|
||||
mac:
|
||||
target: [dmg]
|
||||
category: public.app-category.social-networking
|
||||
@@ -0,0 +1,25 @@
|
||||
import { resolve } from "node:path";
|
||||
import { defineConfig, externalizeDepsPlugin } from "electron-vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
main: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: { rollupOptions: { input: resolve("src/main/index.ts") } },
|
||||
},
|
||||
preload: {
|
||||
plugins: [externalizeDepsPlugin()],
|
||||
build: { rollupOptions: { input: resolve("src/preload/index.ts") } },
|
||||
},
|
||||
renderer: {
|
||||
root: "src/renderer",
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: { "@renderer": resolve("src/renderer/src") },
|
||||
},
|
||||
build: {
|
||||
rollupOptions: { input: resolve("src/renderer/index.html") },
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import js from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import globals from "globals";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["out/**", "dist/**", "node_modules/**", "*.config.js", "*.config.ts"] },
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: { ...globals.node, ...globals.browser },
|
||||
},
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"warn",
|
||||
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
|
||||
],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"no-empty": ["error", { allowEmptyCatch: true }],
|
||||
"no-console": ["warn", { allow: ["warn", "error"] }],
|
||||
},
|
||||
},
|
||||
// The renderer is sandboxed: it must never import electron or the vrchat SDK.
|
||||
// It only talks to the main process through window.api (shared/ipc).
|
||||
{
|
||||
files: ["src/renderer/**/*.{ts,tsx}"],
|
||||
plugins: { "react-hooks": reactHooks, "react-refresh": reactRefresh },
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-hooks/set-state-in-effect": "warn",
|
||||
"react-hooks/refs": "warn",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{ name: "electron", message: "renderer must not import electron; use window.api" },
|
||||
{ name: "vrchat", message: "renderer must not import the vrchat SDK; use window.api" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "vrc-circle",
|
||||
"version": "0.1.0",
|
||||
"description": "An all-in-one VRChat Hub & Launcher",
|
||||
"author": "YuzuZensai <yuzu@kirameki.cafe>",
|
||||
"license": "MIT",
|
||||
"main": "./out/main/index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "electron-vite dev",
|
||||
"build": "pnpm run typecheck && electron-vite build",
|
||||
"preview": "electron-vite preview",
|
||||
"start": "electron-vite preview",
|
||||
"typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false",
|
||||
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false",
|
||||
"typecheck": "pnpm run typecheck:node && pnpm run typecheck:web",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --check .",
|
||||
"format:fix": "prettier --write .",
|
||||
"package": "pnpm run build && electron-builder --dir",
|
||||
"dist": "pnpm run build && electron-builder"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.3.9",
|
||||
"i18next": "^26.3.2",
|
||||
"keyv": "^5.2.3",
|
||||
"keyv-file": "^5.1.2",
|
||||
"lucide-react": "^1.21.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"sharp": "^0.35.2",
|
||||
"vrchat": "^2.1.0",
|
||||
"ws": "^8.18.0",
|
||||
"zustand": "^5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/react": "^19.0.4",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@types/ws": "^8.5.13",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"electron": "^33.3.0",
|
||||
"electron-builder": "^25.1.8",
|
||||
"electron-vite": "^2.3.0",
|
||||
"eslint": "^10.6.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"playwright-core": "^1.61.1",
|
||||
"prettier": "^3.6.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"vite": "^6.0.7"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0"
|
||||
}
|
||||
Generated
+5857
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
onlyBuiltDependencies:
|
||||
- electron
|
||||
- esbuild
|
||||
- unrs-resolver
|
||||
@@ -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");
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import type { VrcCircleApi } from "./index";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
api: VrcCircleApi;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
import type { IpcRequests, IpcEvents } from "../shared/ipc";
|
||||
|
||||
type InvokeFn = <C extends keyof IpcRequests>(
|
||||
channel: C,
|
||||
...args: Parameters<IpcRequests[C]>
|
||||
) => ReturnType<IpcRequests[C]>;
|
||||
|
||||
export interface EventApi {
|
||||
on<C extends keyof IpcEvents>(channel: C, listener: (payload: IpcEvents[C]) => void): () => void;
|
||||
}
|
||||
|
||||
const invoke = ((channel: string, ...args: unknown[]) =>
|
||||
ipcRenderer.invoke(channel, ...args)) as InvokeFn;
|
||||
|
||||
const events: EventApi = {
|
||||
on(channel, listener) {
|
||||
const wrapped = (_e: unknown, payload: unknown) => listener(payload as never);
|
||||
ipcRenderer.on(channel as string, wrapped);
|
||||
return () => ipcRenderer.removeListener(channel as string, wrapped);
|
||||
},
|
||||
};
|
||||
|
||||
export interface VrcCircleApi {
|
||||
invoke: InvokeFn;
|
||||
events: EventApi;
|
||||
platform: NodeJS.Platform;
|
||||
}
|
||||
|
||||
const api: VrcCircleApi = { invoke, events, platform: process.platform };
|
||||
|
||||
contextBridge.exposeInMainWorld("api", api);
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; img-src 'self' https: data: vrcgallery:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src 'self' vrcgallery:; script-src 'self'"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>VRC Circle</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useAuth } from "./features/auth/AuthContext";
|
||||
import { LoginScreen } from "./features/auth/LoginScreen";
|
||||
import { AppShell } from "./components/AppShell";
|
||||
import { Loader } from "./components/ui";
|
||||
|
||||
export function App() {
|
||||
const { status, loading, adding } = useAuth();
|
||||
|
||||
if (loading) return <Loader />;
|
||||
|
||||
const authed = status.state === "authenticated";
|
||||
return authed && !adding ? <AppShell /> : <LoginScreen />;
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleDot,
|
||||
ExternalLink,
|
||||
Images,
|
||||
Search,
|
||||
Settings,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
UserCog,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { ProfileView } from "../features/profile/ProfileView";
|
||||
import { WorldView } from "../features/world/WorldView";
|
||||
import { AccountSettingsView } from "../features/account/AccountSettingsView";
|
||||
import { SearchView } from "../features/search/SearchView";
|
||||
import { SettingsView } from "../features/settings/SettingsView";
|
||||
import { EnhancementsView } from "../features/enhancements/EnhancementsView";
|
||||
import { GalleryView } from "../features/gallery/GalleryView";
|
||||
import { FriendsSidebar } from "../features/friends/FriendsSidebar";
|
||||
import { AccountSwitcher } from "../features/auth/AccountSwitcher";
|
||||
import { LaunchButton } from "../features/game/LaunchButton";
|
||||
import { NavProvider, useNav, type View } from "../features/navigation/NavContext";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { api } from "../lib/api";
|
||||
import "../styles/app-shell.css";
|
||||
|
||||
export function AppShell() {
|
||||
return (
|
||||
<NavProvider>
|
||||
<Shell />
|
||||
</NavProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type NavItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
onClick: () => void;
|
||||
kind?: View["kind"];
|
||||
external?: boolean;
|
||||
};
|
||||
|
||||
function Shell() {
|
||||
const nav = useNav();
|
||||
const { t } = useI18n();
|
||||
const [leftOpen, setLeftOpen] = useState(true);
|
||||
const [friendsOpen, setFriendsOpen] = useState(true);
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
id: "search",
|
||||
label: t("nav:search"),
|
||||
icon: Search,
|
||||
kind: "search",
|
||||
onClick: () => nav.openSearch(),
|
||||
},
|
||||
{
|
||||
id: "gallery",
|
||||
label: t("nav:gallery"),
|
||||
icon: Images,
|
||||
kind: "gallery",
|
||||
onClick: () => nav.openGallery(),
|
||||
},
|
||||
{
|
||||
id: "enhancements",
|
||||
label: t("nav:enhancements"),
|
||||
icon: Sparkles,
|
||||
kind: "enhancements",
|
||||
onClick: () => nav.openEnhancements(),
|
||||
},
|
||||
{
|
||||
id: "account",
|
||||
label: t("nav:account"),
|
||||
icon: UserCog,
|
||||
kind: "account",
|
||||
onClick: () => nav.openAccount(),
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: t("nav:settings"),
|
||||
icon: SlidersHorizontal,
|
||||
kind: "settings",
|
||||
onClick: () => nav.openSettings(),
|
||||
},
|
||||
{
|
||||
id: "debug",
|
||||
label: t("nav:debug"),
|
||||
icon: Settings,
|
||||
external: true,
|
||||
onClick: () => void api.debug.openWindow(),
|
||||
},
|
||||
];
|
||||
|
||||
const openProfile = (id: "me" | string) => nav.openUser(id);
|
||||
const stageKey =
|
||||
nav.current.kind === "user" || nav.current.kind === "world"
|
||||
? `${nav.current.kind}:${nav.current.id}`
|
||||
: nav.current.kind;
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="topbar">
|
||||
<div className="topbar__brand">
|
||||
<span className="topbar__mark" aria-hidden>
|
||||
<CircleDot size={15} />
|
||||
</span>
|
||||
VRC Circle
|
||||
</div>
|
||||
<div className="topbar__actions">
|
||||
<LaunchButton />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className={`body ${leftOpen ? "" : "is-left-collapsed"} ${
|
||||
friendsOpen ? "" : "is-right-collapsed"
|
||||
}`}
|
||||
>
|
||||
<aside className="leftbar">
|
||||
<div className="leftbar__scroll">
|
||||
<nav className="leftbar__nav">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = !item.external && nav.current.kind === item.kind;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
className={`navitem ${active ? "is-active" : ""}`}
|
||||
onClick={item.onClick}
|
||||
title={item.label}
|
||||
>
|
||||
<span className="navitem__ico">
|
||||
<Icon size={16} />
|
||||
</span>
|
||||
<span className="navitem__label">{item.label}</span>
|
||||
{item.external ? (
|
||||
<span className="navitem__hint">
|
||||
<ExternalLink size={13} />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="leftbar__footer">
|
||||
<AccountSwitcher />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="stage">
|
||||
{nav.canBack ? (
|
||||
<button onClick={nav.back} className="stage__back" aria-label="Go back">
|
||||
<ArrowLeft size={16} /> Back
|
||||
</button>
|
||||
) : null}
|
||||
<div key={stageKey} className="stage__inner animate-rise">
|
||||
{nav.current.kind === "world" ? (
|
||||
<WorldView worldId={nav.current.id} />
|
||||
) : nav.current.kind === "account" ? (
|
||||
<AccountSettingsView />
|
||||
) : nav.current.kind === "settings" ? (
|
||||
<SettingsView />
|
||||
) : nav.current.kind === "enhancements" ? (
|
||||
<EnhancementsView />
|
||||
) : nav.current.kind === "gallery" ? (
|
||||
<GalleryView />
|
||||
) : nav.current.kind === "search" ? (
|
||||
<SearchView />
|
||||
) : (
|
||||
<ProfileView target={nav.current.id} />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<FriendsSidebar onOpen={openProfile} />
|
||||
|
||||
<button
|
||||
className="edge-toggle edge-toggle--left"
|
||||
onClick={() => setLeftOpen((v) => !v)}
|
||||
title={leftOpen ? t("nav:hideNav") : t("nav:showNav")}
|
||||
aria-label={t("nav:toggleNav")}
|
||||
>
|
||||
{leftOpen ? <ChevronLeft size={18} /> : <ChevronRight size={18} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="edge-toggle edge-toggle--right"
|
||||
onClick={() => setFriendsOpen((v) => !v)}
|
||||
title={friendsOpen ? "Hide friends" : "Show friends"}
|
||||
aria-label="Toggle friends sidebar"
|
||||
>
|
||||
{friendsOpen ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
function initials(name?: string): string {
|
||||
return (name ?? "?").trim().slice(0, 2).toUpperCase() || "?";
|
||||
}
|
||||
|
||||
export function Avatar({
|
||||
src,
|
||||
name,
|
||||
size = 30,
|
||||
className = "",
|
||||
}: {
|
||||
src?: string;
|
||||
name?: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const style = { width: size, height: size, borderRadius: "50%" };
|
||||
return src ? (
|
||||
<img src={src} alt="" style={style} className={`shrink-0 object-cover ${className}`} />
|
||||
) : (
|
||||
<span
|
||||
style={style}
|
||||
className={`avatar-fallback grid shrink-0 place-items-center bg-surface-hover font-semibold uppercase text-muted ${className}`}
|
||||
>
|
||||
{initials(name)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
const TONE = {
|
||||
neutral: "var(--muted)",
|
||||
accent: "var(--accent)",
|
||||
success: "var(--status-active)",
|
||||
warn: "var(--status-ask)",
|
||||
danger: "var(--danger)",
|
||||
} as const;
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
tone = "neutral",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: keyof typeof TONE;
|
||||
}) {
|
||||
const color = TONE[tone];
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide"
|
||||
style={{ color, background: `color-mix(in srgb, ${color} 15%, transparent)` }}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function Banner({ children, className = "" }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex animate-pop items-center gap-2.5 rounded-sm border px-[13px] py-2.5 text-[13.5px] ${className}`}
|
||||
style={{
|
||||
color: "color-mix(in srgb, var(--danger) 75%, var(--text))",
|
||||
background: "color-mix(in srgb, var(--danger) 12%, transparent)",
|
||||
borderColor: "color-mix(in srgb, var(--danger) 35%, transparent)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ButtonHTMLAttributes } from "react";
|
||||
|
||||
const BASE =
|
||||
"inline-flex items-center justify-center gap-2 rounded-full px-[18px] py-2.5 text-sm font-semibold transition-[transform,background,border-color,box-shadow] duration-[var(--dur)] ease-[var(--ease)] disabled:cursor-not-allowed disabled:opacity-50 active:not-disabled:scale-[0.98]";
|
||||
|
||||
const VARIANT = {
|
||||
primary:
|
||||
"bg-accent text-on-accent hover:not-disabled:brightness-105 hover:not-disabled:shadow-[0_8px_20px_-10px_var(--accent)]",
|
||||
ghost:
|
||||
"border border-border bg-surface-2 text-text hover:not-disabled:border-border-strong hover:not-disabled:bg-surface-hover",
|
||||
danger:
|
||||
"bg-danger text-on-accent hover:not-disabled:brightness-105 hover:not-disabled:shadow-[0_8px_20px_-10px_var(--danger)]",
|
||||
link: "bg-transparent text-text hover:not-disabled:text-accent",
|
||||
} as const;
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = "primary",
|
||||
loading,
|
||||
block,
|
||||
className = "",
|
||||
...rest
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: keyof typeof VARIANT;
|
||||
loading?: boolean;
|
||||
block?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`${BASE} ${VARIANT[variant]} ${block ? "w-full" : ""} ${className}`}
|
||||
disabled={loading || rest.disabled}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="size-3.5 animate-[spin_0.7s_linear_infinite] rounded-full border-2 border-[color-mix(in_srgb,currentColor_35%,transparent)] border-t-current" />
|
||||
) : null}
|
||||
<span className="inline-flex items-center gap-2">{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconButton({
|
||||
active,
|
||||
className = "",
|
||||
children,
|
||||
...rest
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & { active?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
className={`grid size-[38px] shrink-0 place-items-center rounded-sm border bg-surface-2 text-muted transition-[background,color,border-color] duration-[var(--dur)] ease-[var(--ease)] hover:bg-surface-hover hover:text-text ${
|
||||
active
|
||||
? "border-[color-mix(in_srgb,var(--accent)_40%,var(--border))] text-accent"
|
||||
: "border-border hover:border-border-strong"
|
||||
} ${className}`}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
export function CollapsibleCard({
|
||||
title,
|
||||
count,
|
||||
defaultOpen = true,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
count?: number | string;
|
||||
defaultOpen?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-surface-2 p-5 shadow-sm">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`flex w-full items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-faint ${
|
||||
open ? "mb-3" : ""
|
||||
}`}
|
||||
>
|
||||
<span className="text-faint">
|
||||
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
{title}
|
||||
{count !== undefined ? <span className="text-faint/70">{count}</span> : null}
|
||||
</button>
|
||||
{open ? children : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { InputHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export const INPUT_CLASS =
|
||||
"w-full rounded-sm border border-border bg-surface-2 px-3 py-2.5 text-text outline-none transition-[border-color,box-shadow,background] duration-[var(--dur)] ease-[var(--ease)] placeholder:text-faint focus:border-accent focus:bg-surface focus:shadow-[0_0_0_3px_var(--accent-weak)]";
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
className = "",
|
||||
...rest
|
||||
}: InputHTMLAttributes<HTMLInputElement> & { label: string; hint?: ReactNode }) {
|
||||
return (
|
||||
<label className="flex flex-col gap-[7px]">
|
||||
<span className="text-xs font-semibold text-muted">{label}</span>
|
||||
<input className={`${INPUT_CLASS} ${className}`} {...rest} />
|
||||
{hint ? <span className="text-[12.5px] text-muted">{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function LinkPill({ href, children }: { href: string; children: ReactNode }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-full border border-border bg-surface px-3 py-1 text-[12.5px] font-semibold text-muted transition-colors hover:border-accent hover:text-accent"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { CircleDot } from "lucide-react";
|
||||
|
||||
export function Loader({ size = 40, className = "" }: { size?: number; className?: string }) {
|
||||
return (
|
||||
<div className={`boot ${className}`}>
|
||||
<span className="boot__mark" aria-hidden>
|
||||
<CircleDot size={size} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
type ModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
children?: ReactNode;
|
||||
danger?: boolean;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
onConfirm?: () => void;
|
||||
confirmLoading?: boolean;
|
||||
confirmDisabled?: boolean;
|
||||
};
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
icon,
|
||||
children,
|
||||
danger,
|
||||
confirmLabel,
|
||||
cancelLabel = "Cancel",
|
||||
onConfirm,
|
||||
confirmLoading,
|
||||
confirmDisabled,
|
||||
}: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 grid place-items-center p-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
>
|
||||
<button
|
||||
aria-hidden
|
||||
tabIndex={-1}
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 animate-[fade-in_var(--dur)_var(--ease-out)_both] bg-[color-mix(in_srgb,var(--surface)_30%,#000_55%)] backdrop-blur-[2px]"
|
||||
/>
|
||||
<div className="animate-[pop-in_var(--dur)_var(--ease-out)_both] relative w-full max-w-md rounded-xl border border-border bg-surface p-5 shadow-[0_24px_60px_-20px_rgba(0,0,0,0.5)]">
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<h2
|
||||
className={`flex items-center gap-2 text-[15px] font-semibold ${
|
||||
danger ? "text-danger" : "text-text"
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="grid size-7 shrink-0 place-items-center rounded-md text-faint transition-colors hover:bg-surface-hover hover:text-text"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-[13px] text-muted">{children}</div>
|
||||
|
||||
{onConfirm ? (
|
||||
<div className="mt-5 flex justify-end gap-2.5">
|
||||
<Button variant="ghost" onClick={onClose} disabled={confirmLoading}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
<Button
|
||||
variant={danger ? "danger" : "primary"}
|
||||
onClick={onConfirm}
|
||||
loading={confirmLoading}
|
||||
disabled={confirmDisabled}
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function Panel({
|
||||
title,
|
||||
meta,
|
||||
action,
|
||||
children,
|
||||
className = "",
|
||||
}: {
|
||||
title: ReactNode;
|
||||
meta?: ReactNode;
|
||||
action?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={`flex min-h-0 flex-col overflow-hidden rounded-lg border border-border bg-surface ${className}`}
|
||||
>
|
||||
<header className="flex items-center gap-3 border-b border-border px-4 py-3">
|
||||
<h2 className="text-sm font-bold">{title}</h2>
|
||||
{meta ? <span className="text-xs text-faint">{meta}</span> : null}
|
||||
{action ? <div className="ml-auto">{action}</div> : null}
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { UserProfile } from "../../../../shared/types/user";
|
||||
import { avatarOf, isOnline, statusMeta } from "../../lib/vrchat";
|
||||
import { Avatar } from "./Avatar";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
|
||||
export function PresenceAvatar({ user, size = 32 }: { user: UserProfile; size?: number }) {
|
||||
const status = statusMeta[isOnline(user) ? user.status : "offline"];
|
||||
const dot = Math.max(8, Math.round(size * 0.32));
|
||||
return (
|
||||
<span className="relative shrink-0 leading-none">
|
||||
<Avatar src={avatarOf(user)} name={user.displayName} size={size} />
|
||||
<StatusDot
|
||||
color={status.color}
|
||||
size={dot}
|
||||
ring="var(--surface)"
|
||||
title={status.label}
|
||||
className="absolute -bottom-0.5 -right-0.5"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
const HEADING = "text-[11px] font-semibold uppercase tracking-wide text-faint";
|
||||
|
||||
export function Section({
|
||||
title,
|
||||
children,
|
||||
collapsible,
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
collapsible?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(collapsible ? defaultOpen : true);
|
||||
return (
|
||||
<section className="rounded-xl border border-border bg-surface-2 p-5 shadow-sm">
|
||||
{collapsible ? (
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`flex w-full items-center gap-1.5 ${HEADING} ${open ? "mb-3" : ""}`}
|
||||
>
|
||||
<span className="text-faint">
|
||||
{open ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
{title}
|
||||
</button>
|
||||
) : (
|
||||
<h3 className={`mb-3 ${HEADING}`}>{title}</h3>
|
||||
)}
|
||||
{open ? <div className="flex flex-col gap-3">{children}</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function Fact({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="mb-0.5 text-[11px] uppercase tracking-wide text-faint">{label}</dt>
|
||||
<dd
|
||||
className={mono ? "break-all font-mono text-[12px] text-muted" : "text-[13.5px] text-text"}
|
||||
>
|
||||
{value}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
export function SkeletonGrid({
|
||||
count,
|
||||
grid = "grid grid-cols-2 gap-3 sm:grid-cols-3",
|
||||
item = "sk aspect-video rounded-lg",
|
||||
}: {
|
||||
count: number;
|
||||
grid?: string;
|
||||
item?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={grid}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className={item} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function Stat({
|
||||
label,
|
||||
value,
|
||||
tone,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
tone?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-surface-2 px-3 py-2">
|
||||
<div className="text-[10.5px] uppercase tracking-wide text-faint">{label}</div>
|
||||
<div
|
||||
className="mt-0.5 text-lg font-bold tabular-nums"
|
||||
style={tone ? { color: tone } : undefined}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function StatTile({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
live,
|
||||
}: {
|
||||
icon?: ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
live?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface-2 p-4 shadow-sm">
|
||||
<div className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-faint">
|
||||
{icon} {label}
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 text-2xl font-bold tabular-nums"
|
||||
style={live ? { color: "var(--status-active)" } : undefined}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
export function StatusDot({
|
||||
color,
|
||||
size = 11,
|
||||
ring,
|
||||
className = "",
|
||||
...rest
|
||||
}: { color: string; size?: number; ring?: string } & HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<span
|
||||
className={`block rounded-full ${className}`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
background: color,
|
||||
border: ring ? `2px solid ${ring}` : undefined,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export function Tabs<T extends string>({
|
||||
tabs,
|
||||
active,
|
||||
onChange,
|
||||
}: {
|
||||
tabs: { id: T; label: string }[];
|
||||
active: T;
|
||||
onChange: (id: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div role="tablist" className="flex items-center gap-1 border-b border-border">
|
||||
{tabs.map((t) => {
|
||||
const selected = t.id === active;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
onClick={() => onChange(t.id)}
|
||||
className={`relative -mb-px px-4 py-2.5 text-[13px] font-semibold transition-colors ${
|
||||
selected ? "text-accent" : "text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
{selected ? (
|
||||
<span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-accent" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function Tag({ children, color }: { children: ReactNode; color?: string }) {
|
||||
const c = color ?? "var(--muted)";
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full border px-[9px] py-[3px] text-[11.5px] font-semibold"
|
||||
style={{
|
||||
color: c,
|
||||
background: `color-mix(in srgb, ${c} 14%, transparent)`,
|
||||
borderColor: `color-mix(in srgb, ${c} 30%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
export function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
icon,
|
||||
className = "",
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
icon?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative h-6 w-10 shrink-0 rounded-full border-0 p-0 transition-colors ${
|
||||
checked ? "bg-accent" : "bg-surface-hover"
|
||||
} ${disabled ? "cursor-not-allowed opacity-50" : ""} ${className}`}
|
||||
>
|
||||
<span
|
||||
className="absolute left-0.5 top-0.5 flex size-5 items-center justify-center rounded-full shadow transition-transform"
|
||||
style={{
|
||||
background: "var(--on-accent)",
|
||||
transform: checked ? "translateX(16px)" : "translateX(0)",
|
||||
}}
|
||||
>
|
||||
{icon && checked ? <Check size={12} className="text-accent" /> : null}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export { Loader } from "./Loader";
|
||||
export { Button, IconButton } from "./Button";
|
||||
export { Field, INPUT_CLASS } from "./Field";
|
||||
export { Avatar } from "./Avatar";
|
||||
export { Tag } from "./Tag";
|
||||
export { Badge } from "./Badge";
|
||||
export { Banner } from "./Banner";
|
||||
export { StatusDot } from "./StatusDot";
|
||||
export { Panel } from "./Panel";
|
||||
export { Tabs } from "./Tabs";
|
||||
export { Stat } from "./Stat";
|
||||
export { PresenceAvatar } from "./PresenceAvatar";
|
||||
export { CollapsibleCard } from "./CollapsibleCard";
|
||||
export { Modal } from "./Modal";
|
||||
export { Toggle } from "./Toggle";
|
||||
export { Section, Fact } from "./Section";
|
||||
export { StatTile } from "./StatTile";
|
||||
export { SkeletonGrid } from "./SkeletonGrid";
|
||||
export { LinkPill } from "./LinkPill";
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useState } from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import { Banner, Loader, Tabs } from "../../components/ui";
|
||||
import { useAccountSettings } from "./useAccountSettings";
|
||||
import { DisplayNameSection } from "./sections/DisplayNameSection";
|
||||
import { EmailSection } from "./sections/EmailSection";
|
||||
import { PasswordSection } from "./sections/PasswordSection";
|
||||
import { AccountLinksSection } from "./sections/AccountLinksSection";
|
||||
import { TwoFactorSection } from "./sections/TwoFactorSection";
|
||||
import { AgeVerificationSection } from "./sections/AgeVerificationSection";
|
||||
import { PrivacySection } from "./sections/PrivacySection";
|
||||
import { ContentGatingSection } from "./sections/ContentGatingSection";
|
||||
import { UserDataSection } from "./sections/UserDataSection";
|
||||
import { DangerZoneSection } from "./sections/DangerZoneSection";
|
||||
|
||||
const SHELL = "mx-auto flex w-full max-w-[760px] flex-col gap-[18px] px-12 pb-16 pt-10";
|
||||
const SECTIONS = "animate-rise flex flex-col gap-[18px]";
|
||||
|
||||
type AccountTab = "account" | "security" | "privacy" | "data";
|
||||
|
||||
export function AccountSettingsView() {
|
||||
const { t } = useI18n();
|
||||
const { state, set } = useAccountSettings();
|
||||
const [tab, setTab] = useState<AccountTab>("account");
|
||||
|
||||
if (state.status === "loading") return <Loader className="absolute inset-0" />;
|
||||
if (state.status === "error")
|
||||
return (
|
||||
<div className={SHELL}>
|
||||
<Banner>{state.message}</Banner>
|
||||
</div>
|
||||
);
|
||||
|
||||
const s = state.settings;
|
||||
return (
|
||||
<div className={SHELL}>
|
||||
<header>
|
||||
<h1 className="text-[26px] font-bold tracking-[-0.4px]">{t("account:title")}</h1>
|
||||
<p className="mt-1 text-[13.5px] text-muted">
|
||||
<Trans
|
||||
i18nKey="account:subtitle"
|
||||
values={{ name: s.displayName }}
|
||||
components={[<span className="font-semibold text-text" />]}
|
||||
/>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: "account", label: t("account:tabs.account") },
|
||||
{ id: "security", label: t("account:tabs.security") },
|
||||
{ id: "privacy", label: t("account:tabs.privacy") },
|
||||
{ id: "data", label: t("account:tabs.data") },
|
||||
]}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
/>
|
||||
|
||||
{tab === "account" ? (
|
||||
<div className={SECTIONS}>
|
||||
<DisplayNameSection settings={s} onChange={set} />
|
||||
<EmailSection settings={s} onChange={set} />
|
||||
<PasswordSection settings={s} onChange={set} />
|
||||
<AccountLinksSection settings={s} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "security" ? (
|
||||
<div className={SECTIONS}>
|
||||
<TwoFactorSection settings={s} onChange={set} />
|
||||
<AgeVerificationSection settings={s} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "privacy" ? (
|
||||
<div className={SECTIONS}>
|
||||
<PrivacySection settings={s} onChange={set} />
|
||||
<ContentGatingSection settings={s} onChange={set} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "data" ? (
|
||||
<div className={SECTIONS}>
|
||||
<UserDataSection />
|
||||
<DangerZoneSection settings={s} onChange={set} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { AccountSettings } from "../../../../../shared/types/settings";
|
||||
import { useI18n } from "../../../lib/i18n";
|
||||
import { Badge } from "../../../components/ui";
|
||||
import { ExternalButton, Section, WEBSITE_ACCOUNT } from "../ui";
|
||||
|
||||
export function AccountLinksSection({ settings }: { settings: AccountSettings }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Section
|
||||
title={t("account:linkedAccounts.title")}
|
||||
description={t("account:linkedAccounts.description")}
|
||||
>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<LinkRow name="Discord" link={settings.discord} />
|
||||
<LinkRow name="Google" link={settings.google} />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<ExternalButton href={WEBSITE_ACCOUNT}>{t("account:linkedAccounts.manage")}</ExternalButton>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkRow({ name, link }: { name: string; link: AccountSettings["discord"] }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border border-border bg-surface-2 px-4 py-3">
|
||||
<div>
|
||||
<div className="text-[13.5px] font-semibold">{name}</div>
|
||||
{link.linked && link.label ? (
|
||||
<div className="text-[12px] text-muted">{link.label}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{link.linked ? (
|
||||
<Badge tone="success">{t("account:linkedAccounts.linked")}</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">{t("account:linkedAccounts.notLinked")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { AccountSettings } from "../../../../../shared/types/settings";
|
||||
import { useI18n } from "../../../lib/i18n";
|
||||
import { Badge } from "../../../components/ui";
|
||||
import { ExternalButton, Section, WEBSITE_ACCOUNT } from "../ui";
|
||||
|
||||
export function AgeVerificationSection({ settings }: { settings: AccountSettings }) {
|
||||
const { t } = useI18n();
|
||||
const verified = settings.ageVerified || settings.ageVerificationStatus !== "hidden";
|
||||
return (
|
||||
<Section
|
||||
title={t("account:ageVerification.title")}
|
||||
description={t("account:ageVerification.description")}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{verified ? (
|
||||
<Badge tone="success">
|
||||
{settings.ageVerificationStatus === "18+"
|
||||
? t("account:ageVerification.verified18")
|
||||
: t("account:ageVerification.verified")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge tone="neutral">{t("account:ageVerification.notVerified")}</Badge>
|
||||
)}
|
||||
<ExternalButton href={WEBSITE_ACCOUNT}>
|
||||
{verified ? t("account:ageVerification.manage") : t("account:ageVerification.verify")}
|
||||
</ExternalButton>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ContentFilterKey } from "../../../../../shared/types/settings";
|
||||
import { api } from "../../../lib/api";
|
||||
import { useI18n } from "../../../lib/i18n";
|
||||
import { Notice, Section, ToggleRow, useAsync, type SectionProps } from "../ui";
|
||||
|
||||
const FILTER_ORDER: ContentFilterKey[] = [
|
||||
"content_sex",
|
||||
"content_adult",
|
||||
"content_violence",
|
||||
"content_gore",
|
||||
"content_horror",
|
||||
];
|
||||
|
||||
export function ContentGatingSection({ settings, onChange }: SectionProps) {
|
||||
const { t } = useI18n();
|
||||
const { busy, error, run } = useAsync();
|
||||
const active = new Set(settings.contentFilters);
|
||||
|
||||
function toggle(key: ContentFilterKey, on: boolean) {
|
||||
const next = FILTER_ORDER.filter((k) => (k === key ? on : active.has(k)));
|
||||
void run(api.settings.contentFilters(next), { onOk: onChange });
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={t("account:contentGating.title")}
|
||||
description={t("account:contentGating.description")}
|
||||
>
|
||||
{settings.contentFiltersLocked ? (
|
||||
<p className="mb-2 text-[12.5px] text-faint">{t("account:contentGating.locked")}</p>
|
||||
) : null}
|
||||
<div className="divide-y divide-border">
|
||||
{FILTER_ORDER.map((key) => (
|
||||
<ToggleRow
|
||||
key={key}
|
||||
label={t("account:contentGating.filterLabel", {
|
||||
label: t(`account:contentGating.filters.${key}`),
|
||||
})}
|
||||
checked={active.has(key)}
|
||||
onChange={(on) => toggle(key, on)}
|
||||
disabled={busy || settings.contentFiltersLocked}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Notice error={error} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { api } from "../../../lib/api";
|
||||
import { useI18n } from "../../../lib/i18n";
|
||||
import { Button, Field } from "../../../components/ui";
|
||||
import { Notice, Section, useAsync, type SectionProps } from "../ui";
|
||||
|
||||
export function DangerZoneSection({ settings, onChange }: SectionProps) {
|
||||
const { t } = useI18n();
|
||||
const { busy, error, run } = useAsync();
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const canDelete = confirmText.trim().toUpperCase() === "DELETE";
|
||||
|
||||
async function remove() {
|
||||
await run(api.settings.deleteAccount(), {
|
||||
onOk: onChange,
|
||||
okMsg: t("account:dangerZone.scheduledOk"),
|
||||
});
|
||||
setConfirmText("");
|
||||
}
|
||||
|
||||
if (settings.accountDeletionDate) {
|
||||
return (
|
||||
<Section title={t("account:dangerZone.title")} icon={<AlertTriangle size={16} />} danger>
|
||||
<p className="text-[13px]">
|
||||
<Trans
|
||||
i18nKey="account:dangerZone.scheduled"
|
||||
values={{ date: new Date(settings.accountDeletionDate).toLocaleDateString() }}
|
||||
components={[<span className="font-semibold" />]}
|
||||
/>
|
||||
</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={t("account:dangerZone.title")}
|
||||
icon={<AlertTriangle size={16} />}
|
||||
description={t("account:dangerZone.description")}
|
||||
danger
|
||||
>
|
||||
<div className="flex flex-wrap items-end gap-2.5">
|
||||
<Field
|
||||
label={t("account:dangerZone.confirmField")}
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
/>
|
||||
<Button variant="danger" onClick={remove} loading={busy} disabled={!canDelete}>
|
||||
{t("account:dangerZone.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
<Notice error={error} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useState } from "react";
|
||||
import { Clock } from "lucide-react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { api } from "../../../lib/api";
|
||||
import { formatDate } from "../../../lib/format";
|
||||
import { useI18n } from "../../../lib/i18n";
|
||||
import { Button, Field, Modal } from "../../../components/ui";
|
||||
import {
|
||||
Notice,
|
||||
Section,
|
||||
addDays,
|
||||
daysSince,
|
||||
lastChangedLabel,
|
||||
useAsync,
|
||||
type SectionProps,
|
||||
} from "../ui";
|
||||
|
||||
export function DisplayNameSection({ settings, onChange }: SectionProps) {
|
||||
const { t } = useI18n();
|
||||
const [name, setName] = useState(settings.displayName);
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmRevert, setConfirmRevert] = useState(false);
|
||||
const { busy, error, ok, run } = useAsync();
|
||||
|
||||
const cooldownDays = settings.supporter ? 30 : 90;
|
||||
const changedDaysAgo = daysSince(settings.displayNameChangedAt);
|
||||
const inCooldown = changedDaysAgo !== null && changedDaysAgo < cooldownDays;
|
||||
const daysLeft = inCooldown ? cooldownDays - changedDaysAgo : 0;
|
||||
const canRevert =
|
||||
settings.previousDisplayName != null && changedDaysAgo !== null && changedDaysAgo <= 90;
|
||||
const dirty = name.trim() !== settings.displayName && name.trim().length > 0;
|
||||
|
||||
async function submit() {
|
||||
await run(api.settings.displayName(name.trim(), password), {
|
||||
onOk: (next) => {
|
||||
onChange(next);
|
||||
setName(next.displayName);
|
||||
setPassword("");
|
||||
},
|
||||
okMsg: t("account:displayName.updated"),
|
||||
});
|
||||
}
|
||||
|
||||
async function revert() {
|
||||
setConfirmRevert(false);
|
||||
await run(api.settings.revertDisplayName(password), {
|
||||
onOk: (next) => {
|
||||
onChange(next);
|
||||
setName(next.displayName);
|
||||
setPassword("");
|
||||
},
|
||||
okMsg: t("account:displayName.reverted"),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={t("account:displayName.title")}
|
||||
description={
|
||||
settings.supporter
|
||||
? t("account:displayName.descriptionSupporter")
|
||||
: t("account:displayName.description")
|
||||
}
|
||||
>
|
||||
{inCooldown ? (
|
||||
<CooldownNotice
|
||||
changedAt={settings.displayNameChangedAt!}
|
||||
changedDaysAgo={changedDaysAgo!}
|
||||
cooldownDays={cooldownDays}
|
||||
daysLeft={daysLeft}
|
||||
supporter={settings.supporter}
|
||||
canRevert={canRevert}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field
|
||||
label={t("account:displayName.field")}
|
||||
value={name}
|
||||
disabled={inCooldown}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label={t("account:common.currentPassword")}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2.5">
|
||||
{!inCooldown ? (
|
||||
<Button onClick={submit} loading={busy} disabled={!dirty || !password}>
|
||||
{t("account:displayName.save")}
|
||||
</Button>
|
||||
) : null}
|
||||
{canRevert ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmRevert(true)}
|
||||
loading={busy}
|
||||
disabled={!password}
|
||||
title={!password ? t("account:displayName.revertNeedsPassword") : undefined}
|
||||
>
|
||||
{t("account:displayName.revertTo", { name: settings.previousDisplayName })}
|
||||
</Button>
|
||||
) : null}
|
||||
{canRevert && !password ? (
|
||||
<span className="text-[12px] text-faint">
|
||||
{t("account:displayName.enableRevertHint")}
|
||||
</span>
|
||||
) : null}
|
||||
{!inCooldown && lastChangedLabel(t, settings.displayNameChangedAt) ? (
|
||||
<span className="text-[12px] text-faint">
|
||||
{lastChangedLabel(t, settings.displayNameChangedAt)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Notice error={error} ok={ok} />
|
||||
<Modal
|
||||
open={confirmRevert}
|
||||
onClose={() => setConfirmRevert(false)}
|
||||
title={t("account:displayName.revertModal.title")}
|
||||
icon={<Clock size={16} />}
|
||||
confirmLabel={t("account:displayName.revertModal.confirm")}
|
||||
onConfirm={revert}
|
||||
confirmLoading={busy}
|
||||
>
|
||||
<Trans
|
||||
i18nKey="account:displayName.revertModal.body"
|
||||
values={{ name: settings.previousDisplayName, days: cooldownDays }}
|
||||
components={[
|
||||
<span className="font-semibold text-text" />,
|
||||
<span className="font-semibold text-text" />,
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
function CooldownNotice({
|
||||
changedAt,
|
||||
changedDaysAgo,
|
||||
cooldownDays,
|
||||
daysLeft,
|
||||
supporter,
|
||||
canRevert,
|
||||
}: {
|
||||
changedAt: string;
|
||||
changedDaysAgo: number;
|
||||
cooldownDays: number;
|
||||
daysLeft: number;
|
||||
supporter: boolean;
|
||||
canRevert: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const ago =
|
||||
changedDaysAgo <= 0
|
||||
? t("account:displayName.cooldown.agoToday")
|
||||
: t("account:displayName.cooldown.agoDays", { count: changedDaysAgo });
|
||||
const left = t("account:displayName.cooldown.left", { count: daysLeft });
|
||||
return (
|
||||
<div className="mb-4 flex gap-3 rounded-lg border border-border bg-surface p-3.5">
|
||||
<Clock size={16} className="mt-0.5 shrink-0 text-muted" />
|
||||
<div className="text-[12.5px] leading-relaxed">
|
||||
<div className="font-semibold text-text">{t("account:displayName.cooldown.heading")}</div>
|
||||
<p className="mt-1 text-muted">
|
||||
<Trans
|
||||
i18nKey={
|
||||
supporter
|
||||
? "account:displayName.cooldown.bodySupporter"
|
||||
: "account:displayName.cooldown.body"
|
||||
}
|
||||
values={{
|
||||
date: formatDate(changedAt),
|
||||
ago,
|
||||
days: cooldownDays,
|
||||
unlockDate: formatDate(addDays(changedAt, cooldownDays)),
|
||||
left,
|
||||
}}
|
||||
components={[
|
||||
<span className="font-semibold text-text" />,
|
||||
<span className="font-semibold text-text" />,
|
||||
<span className="font-semibold text-text" />,
|
||||
]}
|
||||
/>
|
||||
</p>
|
||||
{canRevert ? (
|
||||
<p className="mt-1.5 text-muted">{t("account:displayName.cooldown.canRevert")}</p>
|
||||
) : null}
|
||||
{!supporter ? (
|
||||
<p className="mt-1.5 text-muted">{t("account:displayName.cooldown.upsell")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../../../lib/api";
|
||||
import { useI18n } from "../../../lib/i18n";
|
||||
import { Button, Field } from "../../../components/ui";
|
||||
import { Notice, Section, useAsync, type SectionProps } from "../ui";
|
||||
|
||||
export function EmailSection({ settings, onChange }: SectionProps) {
|
||||
const { t } = useI18n();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const { busy, error, ok, run } = useAsync();
|
||||
|
||||
async function submit() {
|
||||
await run(api.settings.email(email.trim(), password), {
|
||||
onOk: (next) => {
|
||||
onChange(next);
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
},
|
||||
okMsg: t("account:email.confirmSent"),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={t("account:email.title")}
|
||||
description={
|
||||
<>
|
||||
{t("account:email.current")}
|
||||
<span className="font-mono text-text">{settings.email || t("account:email.none")}</span>
|
||||
{settings.emailVerified ? null : t("account:email.unverified")}
|
||||
{settings.pendingEmail ? (
|
||||
<>
|
||||
{t("account:email.pending")}
|
||||
<span className="font-mono text-text">{settings.pendingEmail}</span>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field
|
||||
label={t("account:email.field")}
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label={t("account:common.currentPassword")}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Button onClick={submit} loading={busy} disabled={!email.includes("@") || !password}>
|
||||
{t("account:email.change")}
|
||||
</Button>
|
||||
</div>
|
||||
<Notice error={error} ok={ok} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../../../lib/api";
|
||||
import { useI18n } from "../../../lib/i18n";
|
||||
import { Button, Field } from "../../../components/ui";
|
||||
import {
|
||||
ExternalButton,
|
||||
Notice,
|
||||
Section,
|
||||
WEBSITE_ACCOUNT,
|
||||
useAsync,
|
||||
type SectionProps,
|
||||
} from "../ui";
|
||||
|
||||
export function PasswordSection({ settings, onChange }: SectionProps) {
|
||||
const { t } = useI18n();
|
||||
const [current, setCurrent] = useState("");
|
||||
const [next, setNext] = useState("");
|
||||
const [confirm, setConfirm] = useState("");
|
||||
const { busy, error, ok, run } = useAsync();
|
||||
const mismatch = confirm.length > 0 && next !== confirm;
|
||||
const valid = current.length > 0 && next.length >= 8 && next === confirm;
|
||||
|
||||
async function submit() {
|
||||
await run(api.settings.password(current, next), {
|
||||
onOk: (res) => {
|
||||
onChange(res);
|
||||
setCurrent("");
|
||||
setNext("");
|
||||
setConfirm("");
|
||||
},
|
||||
okMsg: t("account:password.changed"),
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.usesGeneratedPassword) {
|
||||
return (
|
||||
<Section
|
||||
title={t("account:password.title")}
|
||||
description={t("account:password.generatedDescription")}
|
||||
>
|
||||
<ExternalButton href={WEBSITE_ACCOUNT}>{t("account:password.manageSignIn")}</ExternalButton>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t("account:password.title")} description={t("account:password.description")}>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Field
|
||||
label={t("account:common.currentPassword")}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={current}
|
||||
onChange={(e) => setCurrent(e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label={t("account:password.new")}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={next}
|
||||
onChange={(e) => setNext(e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label={t("account:password.confirm")}
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
hint={mismatch ? t("account:password.mismatch") : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Button onClick={submit} loading={busy} disabled={!valid}>
|
||||
{t("account:password.change")}
|
||||
</Button>
|
||||
</div>
|
||||
<Notice error={error} ok={ok} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { api } from "../../../lib/api";
|
||||
import { useI18n } from "../../../lib/i18n";
|
||||
import { Notice, Section, ToggleRow, useAsync, type SectionProps } from "../ui";
|
||||
|
||||
export function PrivacySection({ settings, onChange }: SectionProps) {
|
||||
const { t } = useI18n();
|
||||
const { busy, error, run } = useAsync();
|
||||
|
||||
function setShared(show: boolean) {
|
||||
void run(api.settings.privacy({ sharedConnectionsHidden: !show }), { onOk: onChange });
|
||||
}
|
||||
|
||||
function setDiscord(show: boolean) {
|
||||
void run(api.settings.privacy({ discordFriendsHidden: !show }), { onOk: onChange });
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t("account:privacy.title")} description={t("account:privacy.description")}>
|
||||
<div className="divide-y divide-border">
|
||||
<ToggleRow
|
||||
label={t("account:privacy.mutual")}
|
||||
hint={t("account:privacy.mutualHint")}
|
||||
checked={!settings.sharedConnectionsHidden}
|
||||
onChange={setShared}
|
||||
disabled={busy}
|
||||
/>
|
||||
<ToggleRow
|
||||
label={t("account:privacy.discord")}
|
||||
hint={t("account:privacy.discordHint")}
|
||||
checked={!settings.discordFriendsHidden}
|
||||
onChange={setDiscord}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<Notice error={error} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, Eye, KeyRound, ShieldCheck } from "lucide-react";
|
||||
import type { RecoveryCode } from "../../../../../shared/types/settings";
|
||||
import { api } from "../../../lib/api";
|
||||
import { useI18n } from "../../../lib/i18n";
|
||||
import { Badge, Button, Field, Modal } from "../../../components/ui";
|
||||
import { useStepUp } from "../../auth/useStepUp";
|
||||
import { TwoFactorPrompt } from "../../auth/TwoFactorPrompt";
|
||||
import { Notice, Section, useAsync, type SectionProps } from "../ui";
|
||||
|
||||
export function TwoFactorSection({ settings, onChange }: SectionProps) {
|
||||
const { t } = useI18n();
|
||||
const [pending, setPending] = useState<{
|
||||
secret: string;
|
||||
qrCodeDataUrl: string;
|
||||
} | null>(null);
|
||||
const [code, setCode] = useState("");
|
||||
const [codes, setCodes] = useState<RecoveryCode[] | null>(null);
|
||||
const setup = useAsync();
|
||||
const verify = useAsync();
|
||||
const [confirmDisable, setConfirmDisable] = useState(false);
|
||||
const [disabledOk, setDisabledOk] = useState(false);
|
||||
const stepUp = useStepUp();
|
||||
|
||||
async function begin() {
|
||||
const res = await setup.run(api.settings.enable2fa());
|
||||
if (res) setPending(res);
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
const res = await verify.run(api.settings.verify2fa(code.trim()));
|
||||
if (!res) return;
|
||||
if (!res.verified) {
|
||||
verify.fail(t("account:twoFactor.incorrectCode"));
|
||||
return;
|
||||
}
|
||||
setPending(null);
|
||||
setCode("");
|
||||
const fresh = await api.settings.get().catch(() => null);
|
||||
if (fresh) onChange(fresh);
|
||||
}
|
||||
|
||||
async function turnOff() {
|
||||
setConfirmDisable(false);
|
||||
await stepUp.run(async () => {
|
||||
const next = await api.settings.disable2fa();
|
||||
onChange(next);
|
||||
setCodes(null);
|
||||
setDisabledOk(true);
|
||||
});
|
||||
}
|
||||
|
||||
async function showCodes() {
|
||||
setDisabledOk(false);
|
||||
await stepUp.run(async () => {
|
||||
setCodes(await api.settings.recoveryCodes());
|
||||
});
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!codes) return;
|
||||
const body = codes.map((c) => c.code).join("\n");
|
||||
const url = URL.createObjectURL(new Blob([body], { type: "text/plain" }));
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = "vrchat-recovery-codes.txt";
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={t("account:twoFactor.title")}
|
||||
icon={<ShieldCheck size={16} />}
|
||||
description={t("account:twoFactor.description")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{settings.twoFactorEnabled ? (
|
||||
<Badge tone="success">{t("account:twoFactor.enabled")}</Badge>
|
||||
) : (
|
||||
<Badge tone="warn">{t("account:twoFactor.disabled")}</Badge>
|
||||
)}
|
||||
{settings.twoFactorEnabledDate ? (
|
||||
<span className="text-[12px] text-faint">
|
||||
{t("account:twoFactor.since", {
|
||||
date: new Date(settings.twoFactorEnabledDate).toLocaleDateString(),
|
||||
})}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{settings.twoFactorEnabled ? (
|
||||
<div className="mt-4 flex flex-col gap-4">
|
||||
<div className="flex flex-wrap gap-2.5">
|
||||
<Button variant="ghost" onClick={showCodes} loading={stepUp.busy && !confirmDisable}>
|
||||
<Eye size={14} />
|
||||
{t("account:twoFactor.showCodes")}
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => setConfirmDisable(true)}>
|
||||
{t("account:twoFactor.disable")}
|
||||
</Button>
|
||||
</div>
|
||||
{codes ? (
|
||||
<div className="rounded-lg border border-border bg-surface-2 p-4">
|
||||
<div className="mb-2.5 flex items-center justify-between">
|
||||
<span className="text-[12px] font-semibold uppercase tracking-wide text-faint">
|
||||
{t("account:twoFactor.recoveryCodes")}
|
||||
</span>
|
||||
<Button variant="ghost" onClick={download}>
|
||||
{t("account:twoFactor.download")}
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="grid grid-cols-2 gap-x-6 gap-y-1.5 font-mono text-[13px] sm:grid-cols-3">
|
||||
{codes.map((c) => (
|
||||
<li
|
||||
key={c.code}
|
||||
className={c.used ? "text-faint line-through" : "text-text"}
|
||||
title={c.used ? t("account:twoFactor.alreadyUsed") : undefined}
|
||||
>
|
||||
{c.code}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-3 text-[12px] text-muted">{t("account:twoFactor.storeSafely")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<Notice
|
||||
error={stepUp.prompt ? null : stepUp.error}
|
||||
ok={disabledOk ? t("account:twoFactor.disabledOk") : null}
|
||||
/>
|
||||
<Modal
|
||||
open={confirmDisable}
|
||||
onClose={() => setConfirmDisable(false)}
|
||||
title={t("account:twoFactor.disableModal.title")}
|
||||
icon={<AlertTriangle size={16} />}
|
||||
danger
|
||||
confirmLabel={t("account:twoFactor.disableModal.confirm")}
|
||||
onConfirm={turnOff}
|
||||
confirmLoading={stepUp.busy}
|
||||
>
|
||||
{t("account:twoFactor.disableModal.body")}
|
||||
</Modal>
|
||||
<TwoFactorPrompt
|
||||
open={!!stepUp.prompt}
|
||||
methods={stepUp.prompt?.methods ?? ["totp"]}
|
||||
busy={stepUp.busy}
|
||||
error={stepUp.error}
|
||||
onSubmit={stepUp.submit}
|
||||
onClose={stepUp.cancel}
|
||||
/>
|
||||
</div>
|
||||
) : pending ? (
|
||||
<div className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-start">
|
||||
<img
|
||||
src={pending.qrCodeDataUrl}
|
||||
alt={t("account:twoFactor.qrAlt")}
|
||||
className="size-40 shrink-0 rounded-lg border border-border bg-surface p-2"
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[13px] text-muted">{t("account:twoFactor.scanHint")}</p>
|
||||
<code className="mt-1.5 block break-all rounded-md border border-border bg-surface-2 px-2.5 py-1.5 font-mono text-[12.5px]">
|
||||
{pending.secret}
|
||||
</code>
|
||||
<div className="mt-3 flex items-end gap-2.5">
|
||||
<Field
|
||||
label={t("account:twoFactor.codeField")}
|
||||
inputMode="numeric"
|
||||
placeholder="123456"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
/>
|
||||
<Button onClick={confirm} loading={verify.busy} disabled={code.trim().length < 6}>
|
||||
{t("account:twoFactor.verifyEnable")}
|
||||
</Button>
|
||||
</div>
|
||||
<Notice error={verify.error} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
<Button onClick={begin} loading={setup.busy}>
|
||||
<KeyRound size={14} />
|
||||
{t("account:twoFactor.enable")}
|
||||
</Button>
|
||||
<Notice error={setup.error} />
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from "react";
|
||||
import { api } from "../../../lib/api";
|
||||
import { useI18n } from "../../../lib/i18n";
|
||||
import { Button } from "../../../components/ui";
|
||||
import { Notice, Section, useAsync } from "../ui";
|
||||
|
||||
export function UserDataSection() {
|
||||
const { t } = useI18n();
|
||||
const { busy, error, ok, run } = useAsync();
|
||||
const [armed, setArmed] = useState(false);
|
||||
|
||||
async function reset() {
|
||||
await run(api.settings.resetUserData(), {
|
||||
okMsg: t("account:userData.done"),
|
||||
});
|
||||
setArmed(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title={t("account:userData.title")} description={t("account:userData.description")}>
|
||||
{armed ? (
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<span className="text-[13px] text-muted">{t("account:userData.confirmPrompt")}</span>
|
||||
<Button variant="danger" onClick={reset} loading={busy}>
|
||||
{t("account:userData.confirm")}
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => setArmed(false)} disabled={busy}>
|
||||
{t("account:common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="ghost" onClick={() => setArmed(true)}>
|
||||
{t("account:userData.reset")}
|
||||
</Button>
|
||||
)}
|
||||
<Notice error={error} ok={ok} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { Check, ExternalLink, X } from "lucide-react";
|
||||
import type { AccountSettings } from "../../../../shared/types/settings";
|
||||
import { errorMessage } from "../../lib/api";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import { Toggle } from "../../components/ui";
|
||||
|
||||
export type TFunc = ReturnType<typeof useI18n>["t"];
|
||||
|
||||
export type SectionProps = {
|
||||
settings: AccountSettings;
|
||||
onChange: (s: AccountSettings) => void;
|
||||
};
|
||||
|
||||
export const WEBSITE_ACCOUNT = "https://vrchat.com/home/profile";
|
||||
|
||||
export function Section({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
danger,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: ReactNode;
|
||||
icon?: ReactNode;
|
||||
danger?: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={`rounded-DEFAULT border px-6 py-[22px] shadow-[var(--shadow-1)] ${
|
||||
danger
|
||||
? "border-[color-mix(in_srgb,var(--danger)_35%,var(--border))] bg-[color-mix(in_srgb,var(--danger)_6%,var(--surface-2))]"
|
||||
: "border-border bg-surface-2"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<h2 className="flex items-center gap-2 text-[15px] font-bold">
|
||||
{icon ? <span className="text-muted">{icon}</span> : null}
|
||||
{title}
|
||||
</h2>
|
||||
{description ? <p className="mt-1 text-[13px] text-muted">{description}</p> : null}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAsync() {
|
||||
const { t } = useI18n();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [ok, setOk] = useState<string | null>(null);
|
||||
|
||||
async function run<T>(p: Promise<T>, opts?: { onOk?: (v: T) => void; okMsg?: string }) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setOk(null);
|
||||
try {
|
||||
const v = await p;
|
||||
opts?.onOk?.(v);
|
||||
if (opts?.okMsg) setOk(opts.okMsg);
|
||||
return v;
|
||||
} catch (e) {
|
||||
setError(errorMessage(e, t("account:common.genericError")));
|
||||
return undefined;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
busy,
|
||||
error,
|
||||
ok,
|
||||
run,
|
||||
fail: (msg: string) => setError(msg),
|
||||
clear: () => (setError(null), setOk(null)),
|
||||
};
|
||||
}
|
||||
|
||||
export function Notice({ error, ok }: { error?: string | null; ok?: string | null }) {
|
||||
if (error)
|
||||
return (
|
||||
<p className="mt-3 flex items-center gap-1.5 text-[12.5px] text-[var(--danger)]">
|
||||
<X size={14} /> {error}
|
||||
</p>
|
||||
);
|
||||
if (ok)
|
||||
return (
|
||||
<p className="mt-3 flex items-center gap-1.5 text-[12.5px] text-[var(--status-active)]">
|
||||
<Check size={14} /> {ok}
|
||||
</p>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ToggleRow({
|
||||
label,
|
||||
hint,
|
||||
checked,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: ReactNode;
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 py-2.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13.5px] font-semibold">{label}</div>
|
||||
{hint ? <div className="mt-0.5 text-[12px] text-muted">{hint}</div> : null}
|
||||
</div>
|
||||
<Toggle checked={checked} onChange={onChange} disabled={disabled} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExternalButton({ href, children }: { href: string; children: ReactNode }) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface-2 px-3.5 py-2 text-[13px] font-semibold text-text transition-colors hover:border-accent hover:text-accent"
|
||||
>
|
||||
{children}
|
||||
<ExternalLink size={13} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export function daysSince(iso?: string): number | null {
|
||||
if (!iso) return null;
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (Number.isNaN(ms)) return null;
|
||||
return Math.floor(ms / 86_400_000);
|
||||
}
|
||||
|
||||
export function addDays(iso: string, days: number): string {
|
||||
const d = new Date(iso);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toISOString();
|
||||
}
|
||||
|
||||
export function lastChangedLabel(t: TFunc, iso?: string): string | null {
|
||||
const days = daysSince(iso);
|
||||
if (days === null) return null;
|
||||
if (days <= 0) return t("account:displayName.lastChanged.today");
|
||||
if (days === 1) return t("account:displayName.lastChanged.yesterday");
|
||||
return t("account:displayName.lastChanged.daysAgo", { count: days });
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { AccountSettings } from "../../../../shared/types/settings";
|
||||
import { api, errorMessage } from "../../lib/api";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
|
||||
type State =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; message: string }
|
||||
| { status: "ready"; settings: AccountSettings };
|
||||
|
||||
export function useAccountSettings(): {
|
||||
state: State;
|
||||
reload: () => void;
|
||||
set: (s: AccountSettings) => void;
|
||||
} {
|
||||
const { status } = useAuth();
|
||||
const activeId = status.state === "authenticated" ? status.user.id : null;
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
|
||||
const reload = useCallback(() => {
|
||||
setState({ status: "loading" });
|
||||
api.settings
|
||||
.get()
|
||||
.then((settings) => setState({ status: "ready", settings }))
|
||||
.catch((err) =>
|
||||
setState({
|
||||
status: "error",
|
||||
message: errorMessage(err, "Failed to load settings."),
|
||||
}),
|
||||
);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
reload();
|
||||
}, [reload, activeId]);
|
||||
|
||||
const set = useCallback(
|
||||
(settings: AccountSettings) => setState({ status: "ready", settings }),
|
||||
[],
|
||||
);
|
||||
|
||||
return { state, reload, set };
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, ChevronDown, LogOut, Plus, Users, X } from "lucide-react";
|
||||
import { useAuth } from "./AuthContext";
|
||||
import { Avatar, StatusDot } from "../../components/ui";
|
||||
import { useSelf } from "../../store/social";
|
||||
import { statusMeta } from "../../lib/vrchat";
|
||||
import { api } from "../../lib/api";
|
||||
import type { UserStatus } from "../../../../shared/types/user";
|
||||
|
||||
const STATUS_CHOICES: UserStatus[] = ["join me", "active", "ask me", "busy"];
|
||||
|
||||
const MENU_ROW =
|
||||
"flex items-center gap-2.5 rounded-sm px-2.5 py-2 text-[12.5px] font-semibold text-muted text-left transition-[background,color] duration-[var(--dur)] ease-[var(--ease)] hover:bg-surface-2 hover:text-text";
|
||||
|
||||
export function AccountSwitcher() {
|
||||
const { accounts, switchAccount, removeAccount, beginAddAccount, logout } = useAuth();
|
||||
const self = useSelf();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [showAccounts, setShowAccounts] = useState(false);
|
||||
|
||||
const active = accounts.accounts.find((a) => a.id === accounts.activeId);
|
||||
const others = accounts.accounts.filter((a) => a.id !== accounts.activeId);
|
||||
const status = self?.status ?? "offline";
|
||||
|
||||
function close() {
|
||||
setOpen(false);
|
||||
setShowAccounts(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{open ? <div className="fixed inset-0 z-40" onClick={close} /> : null}
|
||||
|
||||
<button
|
||||
className={`acct__current group/trigger flex w-full items-center gap-2.5 rounded-DEFAULT border bg-surface-2 py-1.5 pl-1.5 pr-2.5 transition-[background,border-color,box-shadow] duration-[var(--dur)] ease-[var(--ease)] hover:border-border-strong hover:bg-surface-hover ${
|
||||
open
|
||||
? "border-accent bg-surface-hover shadow-[0_0_0_3px_var(--accent-weak)]"
|
||||
: "border-border"
|
||||
}`}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className="acct__current relative shrink-0 leading-[0]">
|
||||
<Avatar src={active?.userIcon} name={active?.displayName} size={32} />
|
||||
<StatusDot
|
||||
color={statusMeta[status].color}
|
||||
ring="var(--surface-2)"
|
||||
title={statusMeta[status].label}
|
||||
className="absolute -bottom-px -right-px transition-[border-color] duration-[var(--dur)] group-hover/trigger:border-[var(--surface-hover)] group-aria-expanded/trigger:border-[var(--surface-hover)]"
|
||||
/>
|
||||
</span>
|
||||
<span className="acct__current-text flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-left text-[13.5px] font-semibold leading-[1.25]">
|
||||
{active?.displayName ?? "Account"}
|
||||
</span>
|
||||
<span className="truncate text-left text-[11px] font-medium text-faint">
|
||||
{self?.statusDescription || statusMeta[status].label}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={`acct__chevron shrink-0 text-faint transition-transform duration-[var(--dur)] ease-[var(--ease)] ${
|
||||
open ? "rotate-180" : ""
|
||||
}`}
|
||||
aria-hidden
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open ? (
|
||||
<div className="animate-rise absolute bottom-[calc(100%+8px)] left-0 z-50 flex w-[300px] origin-bottom-left flex-col rounded-DEFAULT border border-border bg-surface p-2 shadow-[var(--shadow-2)]">
|
||||
<StatusPicker />
|
||||
|
||||
{others.length && showAccounts ? (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-px">
|
||||
{others.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="group/row flex items-center rounded-sm transition-colors hover:bg-surface-2"
|
||||
>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 px-2 py-[7px]"
|
||||
onClick={() => {
|
||||
void switchAccount(a.id);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<Avatar src={a.userIcon} name={a.displayName} size={28} />
|
||||
<span className="truncate text-[13px] font-semibold">{a.displayName}</span>
|
||||
</button>
|
||||
<button
|
||||
className="grid w-[30px] shrink-0 self-stretch place-items-center rounded-sm text-faint opacity-0 transition-[opacity,color,background] duration-[var(--dur)] ease-[var(--ease)] hover:bg-[color-mix(in_srgb,var(--danger)_12%,transparent)] hover:text-danger group-hover/row:opacity-100"
|
||||
title="Remove account"
|
||||
aria-label={`Remove ${a.displayName}`}
|
||||
onClick={() => void removeAccount(a.id)}
|
||||
>
|
||||
<X size={15} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-px">
|
||||
{others.length ? (
|
||||
<button
|
||||
className={`${MENU_ROW} ${showAccounts ? "text-text" : ""}`}
|
||||
onClick={() => setShowAccounts((v) => !v)}
|
||||
>
|
||||
<Users size={15} />
|
||||
Switch account
|
||||
<span className="ml-auto min-w-[18px] rounded-full bg-surface-hover px-1.5 py-px text-center text-[11px] font-bold text-faint">
|
||||
{accounts.accounts.length}
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
className={MENU_ROW}
|
||||
onClick={() => {
|
||||
beginAddAccount();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<Plus size={15} />
|
||||
Add account
|
||||
</button>
|
||||
<button
|
||||
className={`${MENU_ROW} text-danger hover:bg-[color-mix(in_srgb,var(--danger)_12%,transparent)] hover:text-danger`}
|
||||
onClick={() => {
|
||||
void logout();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<LogOut size={15} />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Separator() {
|
||||
return <div className="mx-0.5 my-1.5 h-px bg-border" />;
|
||||
}
|
||||
|
||||
function StatusPicker() {
|
||||
const self = useSelf();
|
||||
const current = self?.status ?? "offline";
|
||||
const [desc, setDesc] = useState(self?.statusDescription ?? "");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setDesc(self?.statusDescription ?? "");
|
||||
}, [self?.id, self?.statusDescription]);
|
||||
|
||||
async function apply(status: UserStatus, description: string) {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.settings.setStatus(status, description.trim());
|
||||
} catch {
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const dirty = desc.trim() !== (self?.statusDescription ?? "");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex flex-col gap-px">
|
||||
{STATUS_CHOICES.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
className={`flex items-center gap-2.5 rounded-sm px-2.5 py-2 text-left text-[12.5px] font-semibold transition-[background,color] duration-[var(--dur)] ease-[var(--ease)] hover:bg-surface-2 ${
|
||||
current === s ? "text-accent" : "text-text"
|
||||
}`}
|
||||
onClick={() => apply(s, desc)}
|
||||
disabled={busy}
|
||||
>
|
||||
<StatusDot color={statusMeta[s].color} size={9} />
|
||||
<span className="min-w-0 flex-1">{statusMeta[s].label}</span>
|
||||
{current === s ? <Check size={15} className="shrink-0 text-accent" /> : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<form
|
||||
className="flex gap-1.5"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (current !== "offline" && dirty) void apply(current, desc);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded-sm border border-border bg-surface-2 px-[11px] py-2 text-[12.5px] text-text outline-none transition-[border-color] duration-[var(--dur)] ease-[var(--ease)] focus:border-accent"
|
||||
placeholder="Set a custom status…"
|
||||
value={desc}
|
||||
maxLength={32}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="grid w-[34px] shrink-0 place-items-center rounded-sm bg-accent text-on-accent transition-[filter,opacity] duration-[var(--dur)] ease-[var(--ease)] hover:not-disabled:brightness-105 disabled:cursor-default disabled:opacity-40"
|
||||
aria-label="Save status"
|
||||
disabled={busy || !dirty || current === "offline"}
|
||||
>
|
||||
<Check size={15} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { AccountsState, AuthStatus, TwoFactorMethod } from "../../../../shared/types/auth";
|
||||
import { api, events } from "../../lib/api";
|
||||
|
||||
interface AuthContextValue {
|
||||
status: AuthStatus;
|
||||
accounts: AccountsState;
|
||||
loading: boolean;
|
||||
adding: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
verify2fa: (method: TwoFactorMethod, code: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
switchAccount: (id: string) => Promise<void>;
|
||||
removeAccount: (id: string) => Promise<void>;
|
||||
beginAddAccount: () => void;
|
||||
cancelAddAccount: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
const EMPTY: AccountsState = { accounts: [], activeId: null };
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [status, setStatus] = useState<AuthStatus>({ state: "unauthenticated" });
|
||||
const [accounts, setAccounts] = useState<AccountsState>(EMPTY);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const refreshAccounts = useCallback(() => {
|
||||
api.accounts
|
||||
.list()
|
||||
.then(setAccounts)
|
||||
.catch(() => setAccounts(EMPTY));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
api.auth
|
||||
.status()
|
||||
.then(setStatus)
|
||||
.catch(() => setStatus({ state: "unauthenticated" }))
|
||||
.finally(() => setLoading(false));
|
||||
refreshAccounts();
|
||||
|
||||
const offAuth = events.on("auth:changed", setStatus);
|
||||
const offAcc = events.on("accounts:changed", setAccounts);
|
||||
return () => {
|
||||
offAuth();
|
||||
offAcc();
|
||||
};
|
||||
}, [refreshAccounts]);
|
||||
|
||||
const login = useCallback(
|
||||
async (username: string, password: string) => {
|
||||
const next = await api.auth.login(username, password);
|
||||
setStatus(next);
|
||||
if (next.state === "authenticated") {
|
||||
setAdding(false);
|
||||
refreshAccounts();
|
||||
}
|
||||
},
|
||||
[refreshAccounts],
|
||||
);
|
||||
|
||||
const verify2fa = useCallback(
|
||||
async (method: TwoFactorMethod, code: string) => {
|
||||
const next = await api.auth.verify2fa(method, code);
|
||||
setStatus(next);
|
||||
if (next.state === "authenticated") {
|
||||
setAdding(false);
|
||||
refreshAccounts();
|
||||
}
|
||||
},
|
||||
[refreshAccounts],
|
||||
);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await api.auth.logout();
|
||||
const next = await api.auth.status();
|
||||
setStatus(next);
|
||||
refreshAccounts();
|
||||
}, [refreshAccounts]);
|
||||
|
||||
const switchAccount = useCallback(
|
||||
async (id: string) => {
|
||||
setStatus(await api.accounts.switch(id));
|
||||
setAdding(false);
|
||||
refreshAccounts();
|
||||
},
|
||||
[refreshAccounts],
|
||||
);
|
||||
|
||||
const removeAccount = useCallback(async (id: string) => {
|
||||
setAccounts(await api.accounts.remove(id));
|
||||
setStatus(await api.auth.status());
|
||||
}, []);
|
||||
|
||||
const beginAddAccount = useCallback(() => setAdding(true), []);
|
||||
const cancelAddAccount = useCallback(() => setAdding(false), []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
status,
|
||||
accounts,
|
||||
loading,
|
||||
adding,
|
||||
login,
|
||||
verify2fa,
|
||||
logout,
|
||||
switchAccount,
|
||||
removeAccount,
|
||||
beginAddAccount,
|
||||
cancelAddAccount,
|
||||
}),
|
||||
[
|
||||
status,
|
||||
accounts,
|
||||
loading,
|
||||
adding,
|
||||
login,
|
||||
verify2fa,
|
||||
logout,
|
||||
switchAccount,
|
||||
removeAccount,
|
||||
beginAddAccount,
|
||||
cancelAddAccount,
|
||||
],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error("useAuth must be used within <AuthProvider>");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { ArrowLeft, CircleDot } from "lucide-react";
|
||||
import type { TwoFactorMethod } from "../../../../shared/types/auth";
|
||||
import { ApiException } from "../../lib/api";
|
||||
import { useAuth } from "./AuthContext";
|
||||
import { Banner, Button, Field } from "../../components/ui";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
export function LoginScreen() {
|
||||
const { status, login, verify2fa, adding, cancelAddAccount, accounts } = useAuth();
|
||||
const { t } = useI18n();
|
||||
const awaiting2fa = status.state === "awaiting2fa";
|
||||
const canGoBack = adding && accounts.accounts.length > 0;
|
||||
|
||||
return (
|
||||
<div className="relative grid h-full place-items-center p-6">
|
||||
<div className="titlebar absolute left-0 top-0" />
|
||||
<main className="animate-rise relative w-full max-w-[380px] rounded-lg border border-border bg-surface px-[30px] pb-6 pt-8 shadow-[var(--shadow-2)]">
|
||||
{canGoBack ? (
|
||||
<button
|
||||
className="absolute left-4 top-4 inline-flex items-center gap-1 rounded-sm px-2 py-[5px] text-[13px] text-muted transition-[color,background] duration-[var(--dur)] ease-[var(--ease)] hover:bg-surface-2 hover:text-text"
|
||||
onClick={cancelAddAccount}
|
||||
>
|
||||
<ArrowLeft size={15} /> {t("auth:back")}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<header className="mb-[26px] flex flex-col items-center gap-1 text-center">
|
||||
<div className="mb-1.5 grid size-[34px] place-items-center text-accent" aria-hidden>
|
||||
<CircleDot size={26} />
|
||||
</div>
|
||||
<h1 className="text-[21px] font-bold tracking-[-0.2px]">VRC Circle</h1>
|
||||
<p className="text-[13.5px] text-muted">
|
||||
{adding ? t("auth:addAccount") : t("auth:tagline")}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{awaiting2fa ? (
|
||||
<TwoFactorForm methods={status.methods} onSubmit={verify2fa} />
|
||||
) : (
|
||||
<CredentialForm onSubmit={login} />
|
||||
)}
|
||||
|
||||
<footer className="mt-[18px] text-center text-[11.5px] leading-normal text-faint">
|
||||
{t("auth:disclaimer")}
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CredentialForm({
|
||||
onSubmit,
|
||||
}: {
|
||||
onSubmit: (username: string, password: string) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSubmit(username.trim(), password);
|
||||
} catch (err) {
|
||||
setError(messageFor(err, t));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-3.5" onSubmit={submit}>
|
||||
<Field
|
||||
label={t("auth:username")}
|
||||
placeholder={t("auth:usernamePlaceholder")}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label={t("auth:password")}
|
||||
type="password"
|
||||
placeholder="••••••••••"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{error ? <Banner>{error}</Banner> : null}
|
||||
<Button type="submit" block loading={busy} disabled={!username || !password}>
|
||||
{t("auth:signIn")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function TwoFactorForm({
|
||||
methods,
|
||||
onSubmit,
|
||||
}: {
|
||||
methods: TwoFactorMethod[];
|
||||
onSubmit: (method: TwoFactorMethod, code: string) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [method, setMethod] = useState<TwoFactorMethod>(methods[0] ?? "totp");
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSubmit(method, code.trim());
|
||||
} catch (err) {
|
||||
setError(messageFor(err, t));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="flex flex-col gap-3.5" onSubmit={submit}>
|
||||
<p className="mb-0.5 text-center text-[13.5px] text-muted">
|
||||
{t("auth:twoFactorPrompt", {
|
||||
source: t(
|
||||
method === "emailOtp" ? "auth:twoFactorEmailSource" : "auth:twoFactorAppSource",
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
|
||||
{methods.length > 1 ? (
|
||||
<div className="flex gap-1.5 rounded-lg bg-surface-2 p-1" role="tablist">
|
||||
{methods.map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={m === method}
|
||||
className={`flex-1 rounded-sm p-2 text-[13px] font-semibold transition-[color,background] duration-[var(--dur)] ease-[var(--ease)] ${
|
||||
m === method ? "bg-surface text-text shadow-[var(--shadow-1)]" : "text-muted"
|
||||
}`}
|
||||
onClick={() => setMethod(m)}
|
||||
>
|
||||
{m === "emailOtp" ? t("auth:tabEmail") : t("auth:tabAuthenticator")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Field
|
||||
label={t("auth:verificationCode")}
|
||||
placeholder="000000"
|
||||
inputMode="numeric"
|
||||
autoFocus
|
||||
autoComplete="one-time-code"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, "").slice(0, 8))}
|
||||
required
|
||||
/>
|
||||
{error ? <Banner>{error}</Banner> : null}
|
||||
<Button type="submit" block loading={busy} disabled={code.length < 6}>
|
||||
{t("auth:verify")}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
type Translate = ReturnType<typeof useI18n>["t"];
|
||||
|
||||
function messageFor(err: unknown, t: Translate): string {
|
||||
if (err instanceof ApiException) {
|
||||
switch (err.error.code) {
|
||||
case "unauthorized":
|
||||
return t("auth:errorUnauthorized");
|
||||
case "invalid_2fa":
|
||||
return t("auth:errorInvalid2fa");
|
||||
case "rate_limited":
|
||||
return t("auth:errorRateLimited");
|
||||
case "network":
|
||||
return t("auth:errorNetwork");
|
||||
default:
|
||||
return err.error.message || t("auth:errorGeneric");
|
||||
}
|
||||
}
|
||||
return t("auth:errorGeneric");
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from "react";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { Field, Modal } from "../../components/ui";
|
||||
|
||||
type TwoFactorMethod = "totp" | "emailOtp";
|
||||
|
||||
export function TwoFactorPrompt({
|
||||
open,
|
||||
methods,
|
||||
busy,
|
||||
error,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
methods: TwoFactorMethod[];
|
||||
busy?: boolean;
|
||||
error?: string | null;
|
||||
onSubmit: (method: TwoFactorMethod, code: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [code, setCode] = useState("");
|
||||
const method = methods[0] ?? "totp";
|
||||
const label = method === "emailOtp" ? "Email code" : "Authenticator code";
|
||||
|
||||
function submit() {
|
||||
if (code.trim().length < 6) return;
|
||||
onSubmit(method, code.trim());
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={() => {
|
||||
setCode("");
|
||||
onClose();
|
||||
}}
|
||||
title="Verify it's you"
|
||||
icon={<ShieldCheck size={16} />}
|
||||
confirmLabel="Verify"
|
||||
onConfirm={submit}
|
||||
confirmLoading={busy}
|
||||
confirmDisabled={code.trim().length < 6}
|
||||
>
|
||||
<p>
|
||||
{method === "emailOtp"
|
||||
? "Enter the code we emailed you to continue."
|
||||
: "Enter the code from your authenticator app to continue."}
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Field
|
||||
label={label}
|
||||
inputMode="numeric"
|
||||
placeholder="123456"
|
||||
autoFocus
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && submit()}
|
||||
/>
|
||||
</div>
|
||||
{error ? <p className="mt-2 text-[12.5px] text-danger">{error}</p> : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState } from "react";
|
||||
import { ApiException, api, errorMessage } from "../../lib/api";
|
||||
|
||||
type TwoFactorMethod = "totp" | "emailOtp";
|
||||
|
||||
export function useStepUp() {
|
||||
const [prompt, setPrompt] = useState<{
|
||||
methods: TwoFactorMethod[];
|
||||
retry: () => Promise<void>;
|
||||
} | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function run(action: () => Promise<void>): Promise<void> {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
if (e instanceof ApiException && e.error.code === "requires_2fa") {
|
||||
setPrompt({ methods: e.error.methods ?? ["totp"], retry: action });
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(method: TwoFactorMethod, code: string) {
|
||||
if (!prompt) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const { verified } = await api.settings.reverify2fa(method, code);
|
||||
if (!verified) {
|
||||
setError("Incorrect code, try again.");
|
||||
return;
|
||||
}
|
||||
const retry = prompt.retry;
|
||||
setPrompt(null);
|
||||
await retry();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e, "Something went wrong."));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
run,
|
||||
prompt,
|
||||
busy,
|
||||
error,
|
||||
submit,
|
||||
cancel: () => (setPrompt(null), setError(null)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState } from "react";
|
||||
import { useSocial } from "../../store/social";
|
||||
import { useWorlds } from "../../store/worlds";
|
||||
import { useCopied, useDebug } from "./useDebug";
|
||||
import { Count, TabButton } from "./ui";
|
||||
import { CacheTab } from "./tabs/CacheTab";
|
||||
import { ReposTab } from "./tabs/ReposTab";
|
||||
import { ThumbnailsTab } from "./tabs/ThumbnailsTab";
|
||||
import { SocialTab } from "./tabs/SocialTab";
|
||||
import { WorldStorePanel } from "./tabs/WorldsTab";
|
||||
import { WsTab } from "./tabs/WebSocketTab";
|
||||
import { LogsTab } from "./tabs/LogsTab";
|
||||
|
||||
type Tab = "cache" | "repos" | "thumbnails" | "social" | "worlds" | "ws" | "logs";
|
||||
|
||||
export function DebugPanel() {
|
||||
const { cache, stats, logs, ws, repoStats, invalidate, clear, clearLogs, clearWs } = useDebug();
|
||||
const [tab, setTab] = useState<Tab>("cache");
|
||||
|
||||
const friendCount = useSocial((s) => Object.values(s.users).filter((u) => u.isFriend).length);
|
||||
const worldCount = useWorlds((s) => Object.keys(s.worlds).length);
|
||||
const repoCount = repoStats.reduce((sum, r) => sum + r.count, 0);
|
||||
const [exported, exportSnapshot] = useCopied();
|
||||
|
||||
function copyEverything() {
|
||||
exportSnapshot(
|
||||
JSON.stringify(
|
||||
{
|
||||
exportedAt: new Date().toISOString(),
|
||||
platform: window.api?.platform,
|
||||
cacheStats: stats,
|
||||
repoStats,
|
||||
counts: { friends: friendCount, worlds: worldCount, ws: ws.length, logs: logs.length },
|
||||
logs,
|
||||
ws,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="debug flex h-full flex-col gap-4">
|
||||
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-border bg-surface p-1">
|
||||
<TabButton active={tab === "cache"} onClick={() => setTab("cache")}>
|
||||
Cache <Count n={cache.length} />
|
||||
</TabButton>
|
||||
<TabButton active={tab === "repos"} onClick={() => setTab("repos")}>
|
||||
Repositories <Count n={repoCount} />
|
||||
</TabButton>
|
||||
<TabButton active={tab === "thumbnails"} onClick={() => setTab("thumbnails")}>
|
||||
Thumbnails
|
||||
</TabButton>
|
||||
<TabButton active={tab === "social"} onClick={() => setTab("social")}>
|
||||
Social <Count n={friendCount} />
|
||||
</TabButton>
|
||||
<TabButton active={tab === "worlds"} onClick={() => setTab("worlds")}>
|
||||
Worlds <Count n={worldCount} />
|
||||
</TabButton>
|
||||
<TabButton active={tab === "ws"} onClick={() => setTab("ws")}>
|
||||
WebSocket <Count n={ws.length} />
|
||||
</TabButton>
|
||||
<TabButton active={tab === "logs"} onClick={() => setTab("logs")}>
|
||||
Logs <Count n={logs.length} />
|
||||
</TabButton>
|
||||
<button
|
||||
className="ml-auto shrink-0 rounded-md px-2.5 py-1 text-[12px] font-medium text-muted transition-colors hover:bg-surface-2 hover:text-accent"
|
||||
onClick={copyEverything}
|
||||
title="Copy a full diagnostic snapshot for bug reports"
|
||||
>
|
||||
{exported ? "Copied!" : "Export snapshot"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "cache" ? (
|
||||
<CacheTab cache={cache} stats={stats} onInvalidate={invalidate} onClear={clear} />
|
||||
) : tab === "repos" ? (
|
||||
<ReposTab repos={repoStats} />
|
||||
) : tab === "thumbnails" ? (
|
||||
<ThumbnailsTab />
|
||||
) : tab === "social" ? (
|
||||
<SocialTab />
|
||||
) : tab === "worlds" ? (
|
||||
<WorldStorePanel />
|
||||
) : tab === "ws" ? (
|
||||
<WsTab ws={ws} onClear={clearWs} />
|
||||
) : (
|
||||
<LogsTab logs={logs} onClear={clearLogs} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { DebugPanel } from "./DebugPanel";
|
||||
|
||||
export function DebugWindow() {
|
||||
return (
|
||||
<div className="h-screen overflow-hidden bg-bg p-5 text-text">
|
||||
<DebugPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, X } from "lucide-react";
|
||||
import type { CacheEntryInfo, CacheStats } from "../../../../../shared/types/debug";
|
||||
import { Badge, Button, Panel, Stat } from "../../../components/ui";
|
||||
import { useCopied, useNow } from "../useDebug";
|
||||
import {
|
||||
Chip,
|
||||
Empty,
|
||||
Meta,
|
||||
SearchInput,
|
||||
cacheStatus,
|
||||
formatBytes,
|
||||
formatDuration,
|
||||
formatTime,
|
||||
statusColor,
|
||||
statusTone,
|
||||
} from "../ui";
|
||||
|
||||
type CacheFilter = "all" | "fresh" | "stale" | "expired";
|
||||
|
||||
const CACHE_GROUPS = ["Worlds", "Users", "Friends", "Search", "Other"] as const;
|
||||
|
||||
function cacheGroupOf(key: string): string {
|
||||
if (key.startsWith("user:worlds:")) return "Worlds";
|
||||
if (key.startsWith("user:search:")) return "Search";
|
||||
if (key === "friends") return "Friends";
|
||||
if (key === "user:me" || key.startsWith("user:")) return "Users";
|
||||
return "Other";
|
||||
}
|
||||
|
||||
export function CacheTab({
|
||||
cache,
|
||||
stats,
|
||||
onInvalidate,
|
||||
onClear,
|
||||
}: {
|
||||
cache: CacheEntryInfo[];
|
||||
stats: CacheStats | null;
|
||||
onInvalidate: (key: string) => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
const now = useNow();
|
||||
const [filter, setFilter] = useState<CacheFilter>("all");
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const hitRate =
|
||||
stats && stats.hits + stats.misses > 0
|
||||
? Math.round((stats.hits / (stats.hits + stats.misses)) * 100)
|
||||
: null;
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return [...cache]
|
||||
.filter((e) => (filter === "all" ? true : cacheStatus(e, now) === filter))
|
||||
.filter((e) => (q ? e.key.toLowerCase().includes(q) : true))
|
||||
.sort((a, b) => a.key.localeCompare(b.key));
|
||||
}, [cache, filter, query, now]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const by = new Map<string, CacheEntryInfo[]>();
|
||||
for (const e of shown) {
|
||||
const g = cacheGroupOf(e.key);
|
||||
(by.get(g) ?? by.set(g, []).get(g)!).push(e);
|
||||
}
|
||||
const order = (g: string) => {
|
||||
const i = CACHE_GROUPS.indexOf(g as (typeof CACHE_GROUPS)[number]);
|
||||
return i === -1 ? CACHE_GROUPS.length : i;
|
||||
};
|
||||
return [...by.entries()].sort((a, b) => order(a[0]) - order(b[0]));
|
||||
}, [shown]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-col gap-4">
|
||||
{stats ? (
|
||||
<Panel
|
||||
title="Cache stats"
|
||||
meta={stats.persisted ? `persisted · ${formatBytes(stats.totalSize)}` : "in-memory"}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2 p-4 sm:grid-cols-4 lg:grid-cols-6">
|
||||
<Stat label="Entries" value={stats.entries} />
|
||||
<Stat label="Fresh" value={stats.fresh} tone="var(--status-active)" />
|
||||
<Stat label="Stale" value={stats.stale} tone="var(--status-ask)" />
|
||||
<Stat label="Expired" value={stats.expired} tone="var(--danger)" />
|
||||
<Stat label="In-flight" value={stats.inflight} />
|
||||
<Stat
|
||||
label="Hit rate"
|
||||
value={hitRate === null ? "—" : `${hitRate}%`}
|
||||
tone="var(--accent)"
|
||||
/>
|
||||
<Stat label="Hits" value={stats.hits} />
|
||||
<Stat label="Misses" value={stats.misses} />
|
||||
<Stat label="Sets" value={stats.sets} />
|
||||
<Stat label="Patches" value={stats.patches} />
|
||||
<Stat label="Revalidated" value={stats.revalidations} />
|
||||
<Stat label="Invalidated" value={stats.invalidations} />
|
||||
</div>
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
<Panel
|
||||
title="Cache entries"
|
||||
meta={`${shown.length}/${cache.length} · pull / TTL`}
|
||||
action={
|
||||
<Button variant="ghost" onClick={onClear} disabled={cache.length === 0}>
|
||||
Clear all
|
||||
</Button>
|
||||
}
|
||||
className="min-h-0 flex-1"
|
||||
>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<SearchInput value={query} onChange={setQuery} placeholder="Filter keys…" />
|
||||
<div className="flex gap-1">
|
||||
{(["all", "fresh", "stale", "expired"] as CacheFilter[]).map((f) => (
|
||||
<Chip key={f} active={filter === f} onClick={() => setFilter(f)}>
|
||||
{f}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-auto">
|
||||
{shown.length === 0 ? (
|
||||
<Empty>{cache.length === 0 ? "Cache is empty." : "No entries match."}</Empty>
|
||||
) : (
|
||||
groups.map(([group, entries]) => (
|
||||
<div key={group}>
|
||||
<div className="sticky top-0 z-10 flex items-center gap-2 bg-surface-2 px-4 py-1.5 text-[10.5px] font-semibold uppercase tracking-wide text-faint">
|
||||
{group}
|
||||
<span className="text-faint/70">{entries.length}</span>
|
||||
</div>
|
||||
{entries.map((e) => (
|
||||
<CacheRow
|
||||
key={e.key}
|
||||
entry={e}
|
||||
now={now}
|
||||
onInvalidate={() => onInvalidate(e.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CacheRow({
|
||||
entry,
|
||||
now,
|
||||
onInvalidate,
|
||||
}: {
|
||||
entry: CacheEntryInfo;
|
||||
now: number;
|
||||
onInvalidate: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [copied, copy] = useCopied();
|
||||
const remaining = entry.expiresAt - now;
|
||||
const total = entry.expiresAt - entry.createdAt;
|
||||
const pct = total > 0 ? Math.max(0, Math.min(100, (remaining / total) * 100)) : 0;
|
||||
const age = Math.max(0, Math.round((now - entry.createdAt) / 1000));
|
||||
const status = cacheStatus(entry, now);
|
||||
|
||||
return (
|
||||
<div className="border-b border-border last:border-b-0">
|
||||
<div className="flex items-center gap-2 px-4 py-2.5">
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-2.5 text-left"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span className="flex w-3 shrink-0 justify-center text-faint">
|
||||
{open ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
</span>
|
||||
<Badge tone={statusTone[status]}>{status}</Badge>
|
||||
<code className="truncate font-mono text-xs text-text">{entry.key}</code>
|
||||
</button>
|
||||
|
||||
<span className="hidden shrink-0 items-center gap-3 text-[11px] tabular-nums text-faint sm:flex">
|
||||
<span title="cache hits">{entry.hits} hits</span>
|
||||
<span title="value size">{formatBytes(entry.size)}</span>
|
||||
<span title="age since cached">{formatDuration(age)} old</span>
|
||||
<span className="w-12 text-right" title="time until stale">
|
||||
{status === "fresh" ? formatDuration(Math.round(remaining / 1000)) : status}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<button
|
||||
className="shrink-0 rounded px-1.5 leading-none text-faint transition-colors hover:text-danger"
|
||||
title="Invalidate"
|
||||
onClick={onInvalidate}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-1.5">
|
||||
<div className="h-1 overflow-hidden rounded-full bg-surface-2">
|
||||
<div
|
||||
className="h-full rounded-full transition-[width] duration-700 ease-linear"
|
||||
style={{ width: `${pct}%`, background: statusColor[status] }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{open ? (
|
||||
<div className="px-4 pb-3">
|
||||
<dl className="mb-2 grid grid-cols-2 gap-x-4 gap-y-1 text-[11px] sm:grid-cols-4">
|
||||
<Meta label="Created" value={formatTime(entry.createdAt)} />
|
||||
<Meta label="Stale at" value={formatTime(entry.expiresAt)} />
|
||||
<Meta label="Hard expiry" value={formatTime(entry.hardExpiresAt)} />
|
||||
<Meta
|
||||
label="Last read"
|
||||
value={entry.lastAccess ? formatTime(entry.lastAccess) : "never"}
|
||||
/>
|
||||
</dl>
|
||||
<div className="relative">
|
||||
<button
|
||||
className="absolute right-2 top-2 rounded border border-border bg-surface px-2 py-0.5 text-[10.5px] font-semibold text-muted transition-colors hover:text-accent"
|
||||
onClick={() => copy(JSON.stringify(entry.value, null, 2))}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
<pre className="max-h-72 overflow-auto rounded-md border border-border bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-muted">
|
||||
{JSON.stringify(entry.value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { LogEntry, LogLevel } from "../../../../../shared/types/debug";
|
||||
import { Button, Panel } from "../../../components/ui";
|
||||
import { Chip, Empty, SearchInput, formatTime } from "../ui";
|
||||
|
||||
const LEVELS: LogLevel[] = ["debug", "info", "warn", "error"];
|
||||
const levelTone: Record<LogLevel, string> = {
|
||||
debug: "var(--faint)",
|
||||
info: "var(--muted)",
|
||||
warn: "var(--status-ask)",
|
||||
error: "var(--danger)",
|
||||
};
|
||||
|
||||
export function LogsTab({ logs, onClear }: { logs: LogEntry[]; onClear: () => void }) {
|
||||
const [min, setMin] = useState<LogLevel>("debug");
|
||||
const [query, setQuery] = useState("");
|
||||
const minIdx = LEVELS.indexOf(min);
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return logs.filter(
|
||||
(l) =>
|
||||
LEVELS.indexOf(l.level) >= minIdx &&
|
||||
(q ? `${l.scope} ${l.message}`.toLowerCase().includes(q) : true),
|
||||
);
|
||||
}, [logs, minIdx, query]);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Logs"
|
||||
meta={`${shown.length}/${logs.length}`}
|
||||
className="min-h-0 flex-1"
|
||||
action={
|
||||
<Button variant="ghost" onClick={onClear} disabled={logs.length === 0}>
|
||||
Clear
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-4 py-2.5">
|
||||
<SearchInput value={query} onChange={setQuery} placeholder="Search logs…" />
|
||||
<div className="flex gap-1">
|
||||
{LEVELS.map((l) => (
|
||||
<Chip key={l} active={min === l} onClick={() => setMin(l)}>
|
||||
≥ {l}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col-reverse overflow-auto p-2 font-mono text-[11.5px] leading-relaxed">
|
||||
{shown.length === 0 ? (
|
||||
<Empty>No logs match.</Empty>
|
||||
) : (
|
||||
shown
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((l) => (
|
||||
<div key={l.id} className="flex gap-2 rounded px-2 py-0.5 hover:bg-surface-2">
|
||||
<span className="shrink-0 tabular-nums text-faint">{formatTime(l.ts)}</span>
|
||||
<span
|
||||
className="w-10 shrink-0 font-semibold uppercase"
|
||||
style={{ color: levelTone[l.level] }}
|
||||
>
|
||||
{l.level}
|
||||
</span>
|
||||
<span className="shrink-0 text-accent">{l.scope}</span>
|
||||
<span className="min-w-0 break-words text-muted">{l.message}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, ChevronRight, Trash2 } from "lucide-react";
|
||||
import type { RepoStats, StoredEntity } from "../../../../../shared/types/repository";
|
||||
import { Button, Panel, Stat } from "../../../components/ui";
|
||||
import { api } from "../../../lib/api";
|
||||
import { useCopied, useNow } from "../useDebug";
|
||||
import {
|
||||
Empty,
|
||||
Meta,
|
||||
SearchInput,
|
||||
formatBytes,
|
||||
formatFieldValue,
|
||||
relativeAge,
|
||||
sourceColor,
|
||||
} from "../ui";
|
||||
|
||||
export function ReposTab({ repos }: { repos: RepoStats[] }) {
|
||||
const now = useNow(2000);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
const totalCount = repos.reduce((s, r) => s + r.count, 0);
|
||||
const totalSize = repos.reduce((s, r) => s + r.totalSize, 0);
|
||||
const pending = repos.reduce((s, r) => s + r.pendingWrites, 0);
|
||||
const dir = repos[0]?.backendFile?.replace(/[^/]+$/, "") ?? null;
|
||||
|
||||
if (selected) {
|
||||
return <RepoInspector name={selected} onBack={() => setSelected(null)} now={now} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-col gap-4">
|
||||
<Panel title="Entity repositories" meta={`persisted · ${formatBytes(totalSize)}`}>
|
||||
<div className="grid grid-cols-2 gap-2 p-4 sm:grid-cols-4">
|
||||
<Stat label="Entities" value={totalCount} />
|
||||
<Stat label="Size" value={formatBytes(totalSize)} />
|
||||
<Stat label="Types" value={repos.length} />
|
||||
<Stat
|
||||
label="Pending writes"
|
||||
value={pending}
|
||||
tone={pending > 0 ? "var(--status-ask)" : undefined}
|
||||
/>
|
||||
</div>
|
||||
{dir ? (
|
||||
<p className="border-t border-border px-4 py-2 text-[11px] text-faint">Stored at {dir}</p>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
{repos.length === 0 ? (
|
||||
<Panel title="By type">
|
||||
<Empty>No active account — repositories load when signed in.</Empty>
|
||||
</Panel>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{repos.map((r) => (
|
||||
<RepoCard key={r.name} repo={r} now={now} onInspect={() => setSelected(r.name)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RepoCard({
|
||||
repo,
|
||||
now,
|
||||
onInspect,
|
||||
}: {
|
||||
repo: RepoStats;
|
||||
now: number;
|
||||
onInspect: () => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState<"flush" | "clear" | null>(null);
|
||||
|
||||
async function flush() {
|
||||
setBusy("flush");
|
||||
try {
|
||||
await api.debug.repoFlush(repo.name);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
async function clear() {
|
||||
setBusy("clear");
|
||||
try {
|
||||
await api.debug.repoClear(repo.name);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel title={repo.name} meta={formatBytes(repo.totalSize)}>
|
||||
<div className="grid grid-cols-2 gap-2 p-3">
|
||||
<Stat label="Entities" value={repo.count} />
|
||||
<Stat
|
||||
label="Pending"
|
||||
value={repo.pendingWrites}
|
||||
tone={repo.pendingWrites > 0 ? "var(--status-ask)" : undefined}
|
||||
/>
|
||||
</div>
|
||||
<dl className="grid grid-cols-2 gap-x-3 gap-y-1 px-3 pb-2 text-[11px]">
|
||||
<Meta
|
||||
label="Last fetch"
|
||||
value={repo.newestFetch ? relativeAge(now, repo.newestFetch) : "—"}
|
||||
/>
|
||||
<Meta
|
||||
label="Oldest read"
|
||||
value={repo.oldestRead ? relativeAge(now, repo.oldestRead) : "—"}
|
||||
/>
|
||||
</dl>
|
||||
<div className="flex items-center gap-1.5 border-t border-border px-3 py-2">
|
||||
<Button variant="ghost" onClick={onInspect} disabled={repo.count === 0}>
|
||||
Inspect
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={flush}
|
||||
loading={busy === "flush"}
|
||||
disabled={repo.pendingWrites === 0}
|
||||
>
|
||||
Flush
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={clear}
|
||||
loading={busy === "clear"}
|
||||
disabled={repo.count === 0}
|
||||
>
|
||||
<Trash2 size={13} /> Clear
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function RepoInspector({ name, onBack, now }: { name: string; onBack: () => void; now: number }) {
|
||||
const [entities, setEntities] = useState<StoredEntity<{ id: string }>[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const reload = () => {
|
||||
setLoading(true);
|
||||
api.debug
|
||||
.repoInspect(name)
|
||||
.then((e) => setEntities(e))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(reload, [name]);
|
||||
|
||||
const shown = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return [...entities]
|
||||
.filter((e) => {
|
||||
if (!q) return true;
|
||||
const blob = `${e.data.id} ${JSON.stringify(e.data).toLowerCase()}`;
|
||||
return blob.includes(q);
|
||||
})
|
||||
.sort((a, b) => (b.meta.lastFetch ?? 0) - (a.meta.lastFetch ?? 0))
|
||||
.slice(0, 300);
|
||||
}, [entities, query]);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={`${name} inspector`}
|
||||
meta={`${shown.length}/${entities.length} shown`}
|
||||
className="min-h-0 flex-1"
|
||||
action={
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="ghost" onClick={reload} loading={loading}>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={onBack}>
|
||||
← Back
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="shrink-0 border-b border-border px-4 py-2.5">
|
||||
<SearchInput value={query} onChange={setQuery} placeholder="Filter by id or any field…" />
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-auto">
|
||||
{shown.length === 0 ? (
|
||||
<Empty>{entities.length === 0 ? "Repository is empty." : "No entities match."}</Empty>
|
||||
) : (
|
||||
shown.map((e) => <EntityRow key={e.data.id} entity={e} now={now} />)
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityRow({ entity, now }: { entity: StoredEntity<{ id: string }>; now: number }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [copied, copy] = useCopied();
|
||||
const data = entity.data as Record<string, unknown>;
|
||||
const label = (data.name ?? data.displayName ?? data.id) as string;
|
||||
const fieldNames = Object.keys(entity.meta.fields).sort();
|
||||
|
||||
return (
|
||||
<div className="border-b border-border last:border-b-0">
|
||||
<button
|
||||
className="flex w-full items-center gap-2.5 px-4 py-2 text-left hover:bg-surface-2"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<span className="flex w-3 shrink-0 justify-center text-faint">
|
||||
{open ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate text-[13px] font-medium">{label}</span>
|
||||
<code className="truncate font-mono text-[10px] text-faint">{entity.data.id}</code>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-faint">
|
||||
{fieldNames.length} fields
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-faint">
|
||||
{relativeAge(now, entity.meta.lastFetch)}
|
||||
</span>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="px-4 pb-3 pl-[2.4rem]">
|
||||
<div className="mb-2 overflow-hidden rounded-md border border-border">
|
||||
<table className="w-full text-[11px]">
|
||||
<thead className="bg-surface-2 text-faint">
|
||||
<tr className="text-left">
|
||||
<th className="px-2 py-1 font-medium">Field</th>
|
||||
<th className="px-2 py-1 font-medium">Value</th>
|
||||
<th className="px-2 py-1 font-medium">Source</th>
|
||||
<th className="px-2 py-1 text-right font-medium">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{fieldNames.map((f) => {
|
||||
const meta = entity.meta.fields[f];
|
||||
return (
|
||||
<tr key={f} className="border-t border-border/50">
|
||||
<td className="px-2 py-1 font-mono text-muted">{f}</td>
|
||||
<td className="max-w-[14rem] truncate px-2 py-1 font-mono text-text">
|
||||
{formatFieldValue(data[f])}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
<span style={{ color: sourceColor(meta.src) }}>{meta.src}</span>
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right tabular-nums text-faint">
|
||||
{meta.at ? relativeAge(now, meta.at) : "stale"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<dl className="mb-2 grid grid-cols-2 gap-x-4 gap-y-1 text-[11px] sm:grid-cols-3">
|
||||
<Meta label="First seen" value={relativeAge(now, entity.meta.firstSeen)} />
|
||||
<Meta label="Last read" value={relativeAge(now, entity.meta.lastRead)} />
|
||||
<Meta label="Last fetch" value={relativeAge(now, entity.meta.lastFetch)} />
|
||||
</dl>
|
||||
<div className="relative">
|
||||
<button
|
||||
className="absolute right-2 top-2 rounded border border-border bg-surface px-2 py-0.5 text-[10.5px] font-semibold text-muted transition-colors hover:text-accent"
|
||||
onClick={() => copy(JSON.stringify(entity, null, 2))}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
<pre className="max-h-72 overflow-auto rounded-md border border-border bg-surface-2 p-3 font-mono text-[11px] leading-relaxed text-muted">
|
||||
{JSON.stringify(entity.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user