mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 10:58:59 +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