From 35f17c9ac73fbcf9a403a54ae11d80f615780f2e Mon Sep 17 00:00:00 2001 From: Yuzu Date: Fri, 3 Jul 2026 04:01:46 +0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20store=20listener=20leak,?= =?UTF-8?q?=20friends=20cap,=20auth=20resilience?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/cache/cache.ts | 15 +- src/main/ipc/handlers.ts | 1 + src/main/lib/http.ts | 35 +++++ src/main/store/avatarStore.ts | 10 +- src/main/store/entityStore.ts | 19 +-- src/main/store/groupStore.ts | 10 +- src/main/store/worldStore.ts | 10 +- src/main/vrchat/authService.ts | 34 ++++- src/main/vrchat/avatarService.ts | 104 +++++--------- src/main/vrchat/errors.ts | 42 ++---- src/main/vrchat/favoriteFolders.ts | 32 +++++ src/main/vrchat/favoriteMove.ts | 78 ++++++++++ src/main/vrchat/friendsService.ts | 33 ++++- src/main/vrchat/worldService.ts | 135 +++++++----------- .../src/features/auth/AccountSwitcher.tsx | 9 +- .../src/features/auth/AuthContext.tsx | 42 ++++-- .../src/features/auth/LoginScreen.tsx | 7 +- .../src/features/debug/tabs/ReposTab.tsx | 20 ++- .../src/features/gallery/GalleryView.tsx | 28 ++-- .../src/features/gallery/useGallery.ts | 16 ++- .../src/features/settings/SettingsView.tsx | 6 +- src/renderer/src/lib/api.ts | 1 + src/renderer/src/lib/useAsync.ts | 11 +- src/renderer/src/store/avatars.ts | 8 +- src/renderer/src/store/groups.ts | 7 +- src/renderer/src/store/worlds.ts | 7 +- src/shared/ipc.ts | 1 + src/shared/types/avatar.ts | 23 +-- src/shared/types/favorites.ts | 16 +++ src/shared/types/world.ts | 23 +-- tests/favoriteFolders.test.ts | 35 +++++ tests/favoriteMove.test.ts | 116 +++++++++++++++ tests/repository.test.ts | 116 +++++++++++++++ 33 files changed, 733 insertions(+), 317 deletions(-) create mode 100644 src/main/lib/http.ts create mode 100644 src/main/vrchat/favoriteFolders.ts create mode 100644 src/main/vrchat/favoriteMove.ts create mode 100644 src/shared/types/favorites.ts create mode 100644 tests/favoriteFolders.test.ts create mode 100644 tests/favoriteMove.test.ts create mode 100644 tests/repository.test.ts diff --git a/src/main/cache/cache.ts b/src/main/cache/cache.ts index 2e775e1..3e50094 100644 --- a/src/main/cache/cache.ts +++ b/src/main/cache/cache.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { writeFileAtomic, writeFileAtomicSync } from "../lib/atomicFile"; import type { CacheEntryInfo, CacheStats } from "../../shared/types/debug"; -import { httpStatusOf } from "../vrchat/errors"; +import { rateLimitDelayMs } from "../lib/http"; interface Entry { value: T; @@ -31,17 +31,6 @@ function byteSize(value: unknown): number { } } -function retryAfterMs(err: unknown): number | null { - if (httpStatusOf(err) !== 429) return null; - const e = (err ?? {}) as { - response?: { headers?: Record }; - headers?: Record; - }; - 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; @@ -230,7 +219,7 @@ export class TtlCache { return value; }) .catch((err) => { - const ms = retryAfterMs(err); + const ms = rateLimitDelayMs(err); if (ms != null) this.rateLimitedUntil = Date.now() + ms; throw err; }) diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 415b364..aa6ae84 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -27,6 +27,7 @@ const handlers = { "auth:status": () => guard(() => auth.checkStatus()), "auth:login": (creds) => guard(() => auth.login(creds)), "auth:verify2fa": (payload) => guard(() => auth.verify2fa(payload)), + "auth:cancel2fa": () => guard(async () => auth.cancel2fa()), "auth:logout": () => guard(() => auth.logout()), "accounts:list": () => guard(async () => auth.listAccountsState()), diff --git a/src/main/lib/http.ts b/src/main/lib/http.ts new file mode 100644 index 0000000..d647253 --- /dev/null +++ b/src/main/lib/http.ts @@ -0,0 +1,35 @@ +export interface HttpLike { + status?: number; + statusCode?: number; + status_code?: number; + response?: { status?: number; headers?: Record }; + headers?: Record; + message?: string; + error?: { status_code?: number; statusCode?: number; status?: number }; +} + +export function httpStatusOf(err: unknown): number | undefined { + const e = (err ?? {}) as HttpLike; + return ( + e.status ?? + e.statusCode ?? + e.status_code ?? + e.response?.status ?? + e.error?.status_code ?? + e.error?.statusCode ?? + e.error?.status + ); +} + +export function retryAfterSecondsOf(err: unknown): number | undefined { + const e = (err ?? {}) as HttpLike; + 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 rateLimitDelayMs(err: unknown): number | null { + if (httpStatusOf(err) !== 429) return null; + const secs = retryAfterSecondsOf(err); + return secs !== undefined ? secs * 1000 : 8000; +} diff --git a/src/main/store/avatarStore.ts b/src/main/store/avatarStore.ts index 3170680..70eb87c 100644 --- a/src/main/store/avatarStore.ts +++ b/src/main/store/avatarStore.ts @@ -27,7 +27,7 @@ class AvatarStore { private mineIds = new Set(); private favorites: FavoriteAvatarFolder[] = []; private favoriteLimits: FavoriteLimits = DEFAULT_LIMITS; - private wired = false; + private unwire: (() => void) | null = null; onChange(fn: Listener): () => void { this.wire(); @@ -36,9 +36,8 @@ class AvatarStore { } private wire(): void { - if (this.wired || !repos.hasActive) return; - this.wired = true; - repos.active.avatars.onChange((c) => { + if (this.unwire || !repos.hasActive) return; + this.unwire = repos.active.avatars.onChange((c) => { this.emit({ type: "upsert", avatar: c.entity }); }); } @@ -91,7 +90,8 @@ class AvatarStore { this.mineIds.clear(); this.favorites = []; this.favoriteLimits = DEFAULT_LIMITS; - this.wired = false; + this.unwire?.(); + this.unwire = null; this.wire(); this.emit({ type: "seed", snapshot: this.snapshot() }); } diff --git a/src/main/store/entityStore.ts b/src/main/store/entityStore.ts index 4f904f1..307e203 100644 --- a/src/main/store/entityStore.ts +++ b/src/main/store/entityStore.ts @@ -10,7 +10,7 @@ type Listener = (change: Change) => void; class EntityStore { private readonly listeners = new Set(); private selfId: string | null = null; - private wired = false; + private unwire: (() => void) | null = null; onChange(fn: Listener): () => void { this.wire(); @@ -19,16 +19,20 @@ class EntityStore { } private wire(): void { - if (this.wired || !repos.hasActive) return; - this.wired = true; - repos.active.users.onChange((c) => { + if (this.unwire || !repos.hasActive) return; + this.unwire = repos.active.users.onChange((c) => { this.emit({ type: "upsert", user: c.entity }); }); } - seed(self: UserProfile, friends: UserProfile[]): void { - this.wired = false; + private rewire(): void { + this.unwire?.(); + this.unwire = null; this.wire(); + } + + seed(self: UserProfile, friends: UserProfile[]): void { + this.rewire(); this.selfId = self.id; const users = repos.active.users; users.upsert(self, "rest:detail"); @@ -70,8 +74,7 @@ class EntityStore { reset(): void { this.selfId = null; - this.wired = false; - this.wire(); + this.rewire(); this.emit({ type: "seed", snapshot: this.snapshot() }); } diff --git a/src/main/store/groupStore.ts b/src/main/store/groupStore.ts index 9e16f52..51afbbf 100644 --- a/src/main/store/groupStore.ts +++ b/src/main/store/groupStore.ts @@ -11,7 +11,7 @@ class GroupStore { private readonly listeners = new Set(); private byUser = new Map>(); private representedByUser = new Map(); - private wired = false; + private unwire: (() => void) | null = null; onChange(fn: Listener): () => void { this.wire(); @@ -20,9 +20,8 @@ class GroupStore { } private wire(): void { - if (this.wired || !repos.hasActive) return; - this.wired = true; - repos.active.groups.onChange((c) => { + if (this.unwire || !repos.hasActive) return; + this.unwire = repos.active.groups.onChange((c) => { this.emit({ type: "upsert", group: c.entity }); }); } @@ -62,7 +61,8 @@ class GroupStore { reset(): void { this.byUser.clear(); this.representedByUser.clear(); - this.wired = false; + this.unwire?.(); + this.unwire = null; this.wire(); this.emit({ type: "seed", snapshot: this.snapshot() }); } diff --git a/src/main/store/worldStore.ts b/src/main/store/worldStore.ts index b2bbb0e..b2b05de 100644 --- a/src/main/store/worldStore.ts +++ b/src/main/store/worldStore.ts @@ -10,7 +10,7 @@ type Listener = (change: Change) => void; class WorldStore { private readonly listeners = new Set(); private byAuthor = new Map>(); - private wired = false; + private unwire: (() => void) | null = null; onChange(fn: Listener): () => void { this.wire(); @@ -19,9 +19,8 @@ class WorldStore { } private wire(): void { - if (this.wired || !repos.hasActive) return; - this.wired = true; - repos.active.worlds.onChange((c) => { + if (this.unwire || !repos.hasActive) return; + this.unwire = repos.active.worlds.onChange((c) => { this.emit({ type: "upsert", world: c.entity }); }); } @@ -55,7 +54,8 @@ class WorldStore { reset(): void { this.byAuthor.clear(); - this.wired = false; + this.unwire?.(); + this.unwire = null; this.wire(); this.emit({ type: "seed", snapshot: this.snapshot() }); } diff --git a/src/main/vrchat/authService.ts b/src/main/vrchat/authService.ts index 2586161..5901dd8 100644 --- a/src/main/vrchat/authService.ts +++ b/src/main/vrchat/authService.ts @@ -15,6 +15,7 @@ import { setActive, } from "../accounts/store"; import { toCurrentUserSummary } from "./mappers"; +import { isTransientError } from "./errors"; import { userCache } from "./userService"; import { clearSessionCookies, syncSessionCookies } from "./cookies"; import { logger } from "../debug/logger"; @@ -47,12 +48,14 @@ function isRealUser(data: unknown): data is RawUser { async function summaryFrom(vrc: VRChatLike): Promise { const { data } = await vrc.getCurrentUser({ throwOnError: true }); - if (!isRealUser(data)) throw new Error("not authenticated"); + if (!isRealUser(data)) throw { status: 401, message: "Not authenticated" }; return toCurrentUserSummary(data); } let pendingTwoFactor: { + creds: LoginCredentials; resolveCode: (code: string) => void; + rejectCode: (err: unknown) => void; methods: TwoFactorMethod[]; loginDone: Promise; } | null = null; @@ -81,7 +84,8 @@ export async function checkStatus(): Promise { await syncSessionCookies(vrc); void seedActiveAccount(); return { state: "authenticated", user }; - } catch { + } catch (err) { + if (isTransientError(err)) throw err; return { state: "unauthenticated" }; } } @@ -96,6 +100,8 @@ export async function login(creds: LoginCredentials): Promise { resolveCode = res; rejectCode = rej; }); + // nothing awaits this if login fails before 2fa is even asked, don't let it reject unhandled + codePromise.catch(() => {}); let signalAwaiting!: (methods: TwoFactorMethod[]) => void; const awaiting = new Promise((res) => { @@ -128,24 +134,40 @@ export async function login(creds: LoginCredentials): Promise { return winner.status; } - pendingTwoFactor = { resolveCode, methods: winner.methods, loginDone }; + pendingTwoFactor = { creds, resolveCode, rejectCode, methods: winner.methods, loginDone }; return { state: "awaiting2fa", methods: winner.methods }; } export async function verify2fa(payload: TwoFactorPayload): Promise { if (!pendingTwoFactor) return { state: "unauthenticated" }; - const { resolveCode, loginDone } = pendingTwoFactor; + const { resolveCode, loginDone, creds } = pendingTwoFactor; resolveCode(payload.code); try { const status = await loginDone; pendingTwoFactor = null; return status; - } catch { + } catch (err) { pendingTwoFactor = null; - return { state: "unauthenticated" }; + if (isTransientError(err)) throw err; + const next = await login(creds); + if (next.state === "awaiting2fa") { + throw { code: "invalid_2fa", message: "Invalid two-factor code." }; + } + return next; } } +export function cancel2fa(): AuthStatus { + if (pendingTwoFactor) { + pendingTwoFactor.rejectCode({ status: 401, message: "Two-factor auth cancelled" }); + pendingTwoFactor.loginDone.catch(() => {}); + pendingTwoFactor = null; + } + clearLoginClient(); + clearPending(); + return { state: "unauthenticated" }; +} + export function listAccountsState(): AccountsState { return listAccounts(); } diff --git a/src/main/vrchat/avatarService.ts b/src/main/vrchat/avatarService.ts index 57ba885..2142a65 100644 --- a/src/main/vrchat/avatarService.ts +++ b/src/main/vrchat/avatarService.ts @@ -9,6 +9,8 @@ import type { MoveResult, } from "../../shared/types/avatar"; import { toAvatar } from "./mappers"; +import { fillSlots, normalizeVisibility } from "./favoriteFolders"; +import { moveOneFavorite, moveManyFavorites, pause, type FavoriteMover } from "./favoriteMove"; import { httpStatusOf, isTransientError } from "./errors"; import { cachedRead } from "./cachedRead"; import { requireActiveClient } from "./client"; @@ -89,26 +91,12 @@ async function fetchFavorites(vrc: VRChat): Promise<{ } function fillAvatarSlots(existing: FavoriteFolder[], max: number): FavoriteFolder[] { - const out = orderSlots(existing, "avatars"); - const taken = new Set(out.map((f) => f.name)); - for (let i = 1; out.length < max && i <= max; i++) { - const name = `avatars${i}`; - if (taken.has(name)) continue; - out.push({ name, displayName: prettyFolderName(name), visibility: "private", avatars: [] }); - } - return out; -} - -function orderSlots(groups: T[], prefix: string): T[] { - const slotNum = (name: string) => { - const m = new RegExp(`^${prefix}(\\d+)$`).exec(name); - return m ? Number(m[1]) : null; - }; - const custom = groups.filter((g) => slotNum(g.name) === null); - const numbered = groups - .filter((g) => slotNum(g.name) !== null) - .sort((a, b) => slotNum(a.name)! - slotNum(b.name)!); - return [...custom, ...numbered]; + return fillSlots(existing, "avatars", max, (name) => ({ + name, + displayName: prettyFolderName(name), + visibility: "private", + avatars: [], + })); } async function fetchFavoriteLimits(vrc: VRChat): Promise { @@ -119,10 +107,6 @@ async function fetchFavoriteLimits(vrc: VRChat): Promise { }; } -function normalizeVisibility(v: string): FavoriteVisibility { - return v === "friends" || v === "public" ? v : "private"; -} - function prettyFolderName(key: string): string { const m = /^avatars(\d+)$/.exec(key); if (m) return `Group ${m[1]}`; @@ -187,6 +171,26 @@ export async function unfavoriteAvatar(avatarId: string): Promise { await reloadFavorites(); } +type AvatarFavoriteRecord = { id: string; favoriteId: string; tags: string[] }; + +function avatarMover(vrc: VRChat): FavoriteMover { + return { + skip: (fav, folder) => Boolean(fav?.tags?.includes(folder)), + canRefavorite: (id) => canRefavorite(vrc, id), + remove: async (fav) => { + await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true }); + }, + add: async (id, folder) => { + await vrc.addFavorite({ + body: { type: "avatar", favoriteId: id, tags: [folder] }, + throwOnError: true, + }); + }, + restore: (fav) => restoreAvatarFavorite(vrc, fav), + reload: () => reloadFavorites(), + }; +} + export async function moveAvatarToFolder( avatarId: string, folder: string, @@ -194,30 +198,19 @@ export async function moveAvatarToFolder( ): Promise { const vrc = requireActiveClient(); const fav = await findFavoriteRecord(vrc, avatarId); - if (fav?.tags?.includes(folder)) return { moved: 0, skipped: [] }; - if (!(await canRefavorite(vrc, avatarId))) return { moved: 0, skipped: [avatarId] }; - if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true }); - try { - await vrc.addFavorite({ - body: { type: "avatar", favoriteId: avatarId, tags: [folder] }, - throwOnError: true, - }); - } catch (err) { - if (fav) await restoreAvatarFavorite(vrc, fav); - if (reload) await reloadFavorites(); - if (isTransientError(err)) throw err; - return { moved: 0, skipped: [avatarId] }; - } - if (reload) await reloadFavorites(); - return { moved: 1, skipped: [] }; + return moveOneFavorite(avatarMover(vrc), fav, avatarId, folder, reload); } export async function unfavoriteAvatars(avatarIds: string[]): Promise { const vrc = requireActiveClient(); const records = await favoriteRecords(vrc); + let first = true; for (const id of avatarIds) { const fav = records.get(id); - if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true }); + if (!fav) continue; + if (!first) await pause(); + first = false; + await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true }); } await reloadFavorites(); } @@ -228,34 +221,7 @@ export async function moveAvatarsToFolder( ): Promise { const vrc = requireActiveClient(); const records = await favoriteRecords(vrc); - const skipped: string[] = []; - let moved = 0; - for (const id of avatarIds) { - if (!(await canRefavorite(vrc, id))) { - skipped.push(id); - continue; - } - const fav = records.get(id); - if (fav?.tags?.includes(folder)) continue; - if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true }); - try { - await vrc.addFavorite({ - body: { type: "avatar", favoriteId: id, tags: [folder] }, - throwOnError: true, - }); - } catch (err) { - if (fav) await restoreAvatarFavorite(vrc, fav); - if (isTransientError(err)) { - await reloadFavorites(); - throw err; - } - skipped.push(id); - continue; - } - moved++; - } - await reloadFavorites(); - return { moved, skipped }; + return moveManyFavorites(avatarMover(vrc), records, avatarIds, folder); } async function canRefavorite(vrc: VRChat, avatarId: string): Promise { diff --git a/src/main/vrchat/errors.ts b/src/main/vrchat/errors.ts index 912d258..e716a29 100644 --- a/src/main/vrchat/errors.ts +++ b/src/main/vrchat/errors.ts @@ -1,31 +1,11 @@ import type { ApiError, ApiErrorCode, IpcResult } from "../../shared/types/result"; +import { httpStatusOf, retryAfterSecondsOf, type HttpLike } from "../lib/http"; -interface HttpLike { - status?: number; - statusCode?: number; - status_code?: number; - response?: { status?: number; headers?: Record }; - headers?: Record; - message?: string; +export { httpStatusOf }; + +interface ErrorLike extends HttpLike { 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); } export function isTransientError(err: unknown): boolean { @@ -34,15 +14,9 @@ export function isTransientError(err: unknown): boolean { return status === 429 || status >= 500; } -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 e = (err ?? {}) as ErrorLike; + const status = httpStatusOf(e); const message = e.message ?? "Unexpected error"; if (e.code) return { code: e.code, message, methods: e.methods }; @@ -57,14 +31,14 @@ export function toApiError(err: unknown): ApiError { return { code, message: "VRChat API rate limit hit. Try again shortly.", - retryAfter: retryAfterOf(e), + retryAfter: retryAfterSecondsOf(e), }; } if (status !== undefined && status >= 500) { return { code, message: "VRChat API is temporarily unavailable. Try again shortly." }; } - return { code, message, retryAfter: retryAfterOf(e) }; + return { code, message, retryAfter: retryAfterSecondsOf(e) }; } export async function guard(fn: () => Promise): Promise> { diff --git a/src/main/vrchat/favoriteFolders.ts b/src/main/vrchat/favoriteFolders.ts new file mode 100644 index 0000000..9060e03 --- /dev/null +++ b/src/main/vrchat/favoriteFolders.ts @@ -0,0 +1,32 @@ +import type { FavoriteVisibility } from "../../shared/types/favorites"; + +export function normalizeVisibility(v: string): FavoriteVisibility { + return v === "friends" || v === "public" ? v : "private"; +} + +export function orderSlots(groups: T[], prefix: string): T[] { + const slotNum = (name: string) => { + const m = new RegExp(`^${prefix}(\\d+)$`).exec(name); + return m ? Number(m[1]) : null; + }; + const custom = groups.filter((g) => slotNum(g.name) === null); + const numbered = groups + .filter((g) => slotNum(g.name) !== null) + .sort((a, b) => slotNum(a.name)! - slotNum(b.name)!); + return [...custom, ...numbered]; +} + +export function fillSlots( + existing: T[], + prefix: string, + max: number, + makeEmpty: (name: string) => T, +): T[] { + const out = orderSlots(existing, prefix); + const taken = new Set(out.map((g) => g.name)); + for (let i = 1; out.length < max && i <= max; i++) { + const name = `${prefix}${i}`; + if (!taken.has(name)) out.push(makeEmpty(name)); + } + return out; +} diff --git a/src/main/vrchat/favoriteMove.ts b/src/main/vrchat/favoriteMove.ts new file mode 100644 index 0000000..9e505fc --- /dev/null +++ b/src/main/vrchat/favoriteMove.ts @@ -0,0 +1,78 @@ +import type { MoveResult } from "../../shared/types/favorites"; +import { isTransientError } from "./errors"; + +export interface FavoriteMover { + skip: (rec: Rec | undefined, folder: string) => boolean; + canRefavorite: (id: string) => Promise; + remove: (rec: Rec) => Promise; + add: (id: string, folder: string) => Promise; + restore: (rec: Rec) => Promise; + reload: () => Promise; + onMoved?: (id: string, folder: string) => void; +} + +const BULK_PACE_MS = 350; + +export function pause(ms = BULK_PACE_MS): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +export async function moveOneFavorite( + m: FavoriteMover, + rec: Rec | undefined, + id: string, + folder: string, + reload: boolean, +): Promise { + if (m.skip(rec, folder)) return { moved: 0, skipped: [] }; + if (!(await m.canRefavorite(id))) return { moved: 0, skipped: [id] }; + if (rec) await m.remove(rec); + try { + await m.add(id, folder); + } catch (err) { + if (rec) await m.restore(rec); + if (reload) await m.reload(); + if (isTransientError(err)) throw err; + return { moved: 0, skipped: [id] }; + } + if (reload) await m.reload(); + m.onMoved?.(id, folder); + return { moved: 1, skipped: [] }; +} + +export async function moveManyFavorites( + m: FavoriteMover, + records: Map, + ids: string[], + folder: string, +): Promise { + const skipped: string[] = []; + let moved = 0; + let first = true; + for (const id of ids) { + const rec = records.get(id); + if (m.skip(rec, folder)) continue; + if (!first) await pause(); + first = false; + if (!(await m.canRefavorite(id))) { + skipped.push(id); + continue; + } + if (rec) await m.remove(rec); + try { + await m.add(id, folder); + } catch (err) { + if (rec) await m.restore(rec); + if (isTransientError(err)) { + await m.reload(); + throw err; + } + skipped.push(id); + continue; + } + m.onMoved?.(id, folder); + moved++; + } + await m.reload(); + return { moved, skipped }; +} diff --git a/src/main/vrchat/friendsService.ts b/src/main/vrchat/friendsService.ts index 7587a8b..8b824e3 100644 --- a/src/main/vrchat/friendsService.ts +++ b/src/main/vrchat/friendsService.ts @@ -13,21 +13,40 @@ const order: Record = { offline: 4, }; +const PAGE_SIZE = 100; + +type FriendPage = NonNullable< + Awaited["getFriends"]>>["data"] +>; + +async function allFriendPages( + vrc: ReturnType, + offline: boolean, +): Promise { + const out: FriendPage = []; + for (let offset = 0; ; offset += PAGE_SIZE) { + const { data } = await vrc.getFriends({ + query: { offline, n: PAGE_SIZE, offset }, + throwOnError: true, + }); + const page = data ?? []; + out.push(...page); + if (page.length < PAGE_SIZE) return out; + } +} + export async function listFriends(): Promise { 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 }), + allFriendPages(vrc, false), + allFriendPages(vrc, 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, - })), + ...online.map((u) => ({ ...toUserProfile(u, self), state: "online" as const })), + ...offline.map((u) => ({ ...toUserProfile(u, self), state: "offline" as const })), ]; return friends.sort( (a, b) => diff --git a/src/main/vrchat/worldService.ts b/src/main/vrchat/worldService.ts index f6f8e39..f5614dd 100644 --- a/src/main/vrchat/worldService.ts +++ b/src/main/vrchat/worldService.ts @@ -3,7 +3,6 @@ import type { DiscoverCategory, FavoriteGroupEdit, FavoriteLimits, - FavoriteVisibility, FavoriteWorldFolder, MoveResult, World, @@ -16,6 +15,8 @@ import { type WorldFavoriteGroupType, } from "./rawEndpoints"; import { toWorld } from "./mappers"; +import { fillSlots, normalizeVisibility } from "./favoriteFolders"; +import { moveOneFavorite, moveManyFavorites, pause, type FavoriteMover } from "./favoriteMove"; import { cachedRead } from "./cachedRead"; import { requireActiveClient } from "./client"; import { userCache, currentUser } from "./userService"; @@ -97,6 +98,8 @@ async function loadFavoriteWorlds(vrc: VRChat, userId: string): Promise g.vrcPlus === vrcPlus), - prefix, + out.push( + ...fillSlots( + existing.filter((g) => g.vrcPlus === vrcPlus), + prefix, + max, + (name): FavoriteGroupInput => ({ + name, + displayName: prettyFolderName(name), + visibility: "private", + worlds: [], + vrcPlus, + }), + ), ); - const taken = new Set(mine.map((g) => g.name)); - for (let i = 1; mine.length < max && i <= max; i++) { - const name = `${prefix}${i}`; - if (taken.has(name)) continue; - mine.push({ - name, - displayName: prettyFolderName(name), - visibility: "private", - worlds: [], - vrcPlus, - }); - } - out.push(...mine); } return out; } -function orderSlots(groups: T[], prefix: string): T[] { - const slotNum = (name: string) => { - const m = new RegExp(`^${prefix}(\\d+)$`).exec(name); - return m ? Number(m[1]) : null; - }; - const custom = groups.filter((g) => slotNum(g.name) === null); - const numbered = groups - .filter((g) => slotNum(g.name) !== null) - .sort((a, b) => slotNum(a.name)! - slotNum(b.name)!); - return [...custom, ...numbered]; -} - type WorldFavoriteCaps = { world: number; vrcPlusWorld: number }; async function fetchFavoriteLimits( @@ -302,10 +293,6 @@ async function fetchFavoriteLimits( }; } -function normalizeVisibility(v: string): FavoriteVisibility { - return v === "friends" || v === "public" ? v : "private"; -} - export async function favoriteWorld(worldId: string, folder = DEFAULT_FOLDER): Promise { const vrc = requireActiveClient(); await vrc.addFavorite({ @@ -322,6 +309,28 @@ export async function unfavoriteWorld(worldId: string): Promise { await reloadMyFavorites(); } +function worldMover(vrc: VRChat, meId: string): FavoriteMover { + let typeOf: Promise | null = null; + const folderType = (folder: string) => + (typeOf ??= favoriteTypeForExistingFolder(vrc, meId, folder)); + return { + skip: isOnlyInFolder, + canRefavorite: (id) => canRefavorite(vrc, id), + remove: async (fav) => { + await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true }); + }, + add: async (id, folder) => { + await vrc.addFavorite({ + body: favoriteBody(await folderType(folder), id, [folder]), + throwOnError: true, + }); + }, + restore: (fav) => restoreWorldFavorite(vrc, fav), + reload: () => reloadMyFavorites(), + onMoved: (id, folder) => worldFavoritesStore.moveWorld(id, folder), + }; +} + export async function moveWorldToFolder( worldId: string, folder: string, @@ -330,32 +339,19 @@ export async function moveWorldToFolder( const vrc = requireActiveClient(); const me = await currentUser(); const fav = await findFavoriteRecord(vrc, worldId); - if (isOnlyInFolder(fav, folder)) return { moved: 0, skipped: [] }; - if (!(await canRefavorite(vrc, worldId))) return { moved: 0, skipped: [worldId] }; - const type = await favoriteTypeForExistingFolder(vrc, me.id, folder); - if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true }); - try { - await vrc.addFavorite({ - body: favoriteBody(type, worldId, [folder]), - throwOnError: true, - }); - } catch (err) { - if (fav) await restoreWorldFavorite(vrc, fav); - if (reload) await reloadMyFavorites(); - if (isTransientError(err)) throw err; - return { moved: 0, skipped: [worldId] }; - } - if (reload) await reloadMyFavorites(); - worldFavoritesStore.moveWorld(worldId, folder); - return { moved: 1, skipped: [] }; + return moveOneFavorite(worldMover(vrc, me.id), fav, worldId, folder, reload); } export async function unfavoriteWorlds(worldIds: string[]): Promise { const vrc = requireActiveClient(); const records = await favoriteRecords(vrc); + let first = true; for (const id of worldIds) { const fav = records.get(id); - if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true }); + if (!fav) continue; + if (!first) await pause(); + first = false; + await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true }); } await reloadMyFavorites(); } @@ -364,36 +360,7 @@ export async function moveWorldsToFolder(worldIds: string[], folder: string): Pr const vrc = requireActiveClient(); const me = await currentUser(); const records = await favoriteRecords(vrc); - const type = await favoriteTypeForExistingFolder(vrc, me.id, folder); - const skipped: string[] = []; - let moved = 0; - for (const id of worldIds) { - if (!(await canRefavorite(vrc, id))) { - skipped.push(id); - continue; - } - const fav = records.get(id); - if (isOnlyInFolder(fav, folder)) continue; - if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true }); - try { - await vrc.addFavorite({ - body: favoriteBody(type, id, [folder]), - throwOnError: true, - }); - } catch (err) { - if (fav) await restoreWorldFavorite(vrc, fav); - if (isTransientError(err)) { - await reloadMyFavorites(); - throw err; - } - skipped.push(id); - continue; - } - moved++; - } - await reloadMyFavorites(); - for (const id of worldIds) if (!skipped.includes(id)) worldFavoritesStore.moveWorld(id, folder); - return { moved, skipped }; + return moveManyFavorites(worldMover(vrc, me.id), records, worldIds, folder); } async function restoreWorldFavorite(vrc: VRChat, fav: WorldFavoriteRecord): Promise { diff --git a/src/renderer/src/features/auth/AccountSwitcher.tsx b/src/renderer/src/features/auth/AccountSwitcher.tsx index d67dce5..23d2f37 100644 --- a/src/renderer/src/features/auth/AccountSwitcher.tsx +++ b/src/renderer/src/features/auth/AccountSwitcher.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Check, ChevronDown, LogOut, Plus, Users, X } from "lucide-react"; import { useAuth } from "./AuthContext"; import { Avatar, StatusDot } from "../../components/ui"; @@ -161,10 +161,13 @@ function StatusPicker() { const current = self?.status ?? "offline"; const [desc, setDesc] = useState(self?.statusDescription ?? ""); const [busy, setBusy] = useState(false); + const descKey = `${self?.id ?? ""}\n${self?.statusDescription ?? ""}`; + const [prevDescKey, setPrevDescKey] = useState(descKey); - useEffect(() => { + if (descKey !== prevDescKey) { + setPrevDescKey(descKey); setDesc(self?.statusDescription ?? ""); - }, [self?.id, self?.statusDescription]); + } async function apply(status: UserStatus, description: string) { setBusy(true); diff --git a/src/renderer/src/features/auth/AuthContext.tsx b/src/renderer/src/features/auth/AuthContext.tsx index 78856eb..e213133 100644 --- a/src/renderer/src/features/auth/AuthContext.tsx +++ b/src/renderer/src/features/auth/AuthContext.tsx @@ -8,7 +8,7 @@ import { type ReactNode, } from "react"; import type { AccountsState, AuthStatus, TwoFactorMethod } from "../../../../shared/types/auth"; -import { api, events } from "../../lib/api"; +import { api, events, isRetryableApiError } from "../../lib/api"; interface AuthContextValue { status: AuthStatus; @@ -17,6 +17,7 @@ interface AuthContextValue { adding: boolean; login: (username: string, password: string) => Promise; verify2fa: (method: TwoFactorMethod, code: string) => Promise; + cancel2fa: () => Promise; logout: () => Promise; switchAccount: (id: string) => Promise; removeAccount: (id: string) => Promise; @@ -27,6 +28,10 @@ interface AuthContextValue { const AuthContext = createContext(null); const EMPTY: AccountsState = { accounts: [], activeId: null }; +function statusOrLoggedOut(): Promise { + return api.auth.status().catch(() => ({ state: "unauthenticated" as const })); +} + export function AuthProvider({ children }: { children: ReactNode }) { const [status, setStatus] = useState({ state: "unauthenticated" }); const [accounts, setAccounts] = useState(EMPTY); @@ -41,16 +46,30 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); useEffect(() => { - api.auth - .status() - .then(setStatus) - .catch(() => setStatus({ state: "unauthenticated" })) - .finally(() => setLoading(false)); + let cancelled = false; + const check = async (attempt: number): Promise => { + try { + const next = await api.auth.status(); + if (cancelled) return; + setStatus(next); + setLoading(false); + } catch (err) { + if (cancelled) return; + if (isRetryableApiError(err) && attempt < 3) { + window.setTimeout(() => void check(attempt + 1), 1500 * 2 ** attempt); + return; + } + setStatus({ state: "unauthenticated" }); + setLoading(false); + } + }; + void check(0); refreshAccounts(); const offAuth = events.on("auth:changed", setStatus); const offAcc = events.on("accounts:changed", setAccounts); return () => { + cancelled = true; offAuth(); offAcc(); }; @@ -80,10 +99,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { [refreshAccounts], ); + const cancel2fa = useCallback(async () => { + setStatus(await api.auth.cancel2fa()); + }, []); + const logout = useCallback(async () => { await api.auth.logout(); - const next = await api.auth.status(); - setStatus(next); + setStatus(await statusOrLoggedOut()); refreshAccounts(); }, [refreshAccounts]); @@ -98,7 +120,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const removeAccount = useCallback(async (id: string) => { setAccounts(await api.accounts.remove(id)); - setStatus(await api.auth.status()); + setStatus(await statusOrLoggedOut()); }, []); const beginAddAccount = useCallback(() => setAdding(true), []); @@ -112,6 +134,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { adding, login, verify2fa, + cancel2fa, logout, switchAccount, removeAccount, @@ -125,6 +148,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { adding, login, verify2fa, + cancel2fa, logout, switchAccount, removeAccount, diff --git a/src/renderer/src/features/auth/LoginScreen.tsx b/src/renderer/src/features/auth/LoginScreen.tsx index f86de8e..3ebc361 100644 --- a/src/renderer/src/features/auth/LoginScreen.tsx +++ b/src/renderer/src/features/auth/LoginScreen.tsx @@ -7,10 +7,11 @@ import { Banner, Button, Field } from "../../components/ui"; import { useI18n } from "../../lib/i18n"; export function LoginScreen() { - const { status, login, verify2fa, adding, cancelAddAccount, accounts } = useAuth(); + const { status, login, verify2fa, cancel2fa, adding, cancelAddAccount, accounts } = useAuth(); const { t } = useI18n(); const awaiting2fa = status.state === "awaiting2fa"; - const canGoBack = adding && accounts.accounts.length > 0; + const canGoBack = awaiting2fa || (adding && accounts.accounts.length > 0); + const goBack = awaiting2fa ? () => void cancel2fa() : cancelAddAccount; return (
@@ -19,7 +20,7 @@ export function LoginScreen() { {canGoBack ? ( diff --git a/src/renderer/src/features/debug/tabs/ReposTab.tsx b/src/renderer/src/features/debug/tabs/ReposTab.tsx index 8702549..9f4dbe4 100644 --- a/src/renderer/src/features/debug/tabs/ReposTab.tsx +++ b/src/renderer/src/features/debug/tabs/ReposTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { ChevronDown, ChevronRight, Trash2 } from "lucide-react"; import type { RepoStats, StoredEntity } from "../../../../../shared/types/repository"; import { Button, CodeBlock, Panel, Stat } from "../../../components/ui"; @@ -137,16 +137,28 @@ function RepoInspector({ name, onBack, now }: { name: string; onBack: () => void const [entities, setEntities] = useState[]>([]); const [query, setQuery] = useState(""); const [loading, setLoading] = useState(true); + const [prevName, setPrevName] = useState(name); - const reload = () => { + if (prevName !== name) { + setPrevName(name); setLoading(true); - api.debug + } + + const fetchEntities = useCallback(() => { + return api.debug .repoInspect(name) .then((e) => setEntities(e)) .finally(() => setLoading(false)); + }, [name]); + + const reload = () => { + setLoading(true); + void fetchEntities(); }; - useEffect(reload, [name]); + useEffect(() => { + void fetchEntities(); + }, [fetchEntities]); const shown = useMemo(() => { const q = query.trim().toLowerCase(); diff --git a/src/renderer/src/features/gallery/GalleryView.tsx b/src/renderer/src/features/gallery/GalleryView.tsx index 7fff9a0..aa9ee1c 100644 --- a/src/renderer/src/features/gallery/GalleryView.tsx +++ b/src/renderer/src/features/gallery/GalleryView.tsx @@ -31,13 +31,13 @@ export function GalleryView() { const { snap, loading, error, reload, remove, recent } = useGallery(); const [query, setQuery] = useState(""); const [activeId, setActiveId] = useState(null); - const [selected, setSelected] = useState>(() => new Set()); + const [selectedRaw, setSelected] = useState>(() => new Set()); const scrollRef = useRef(null); const bandRef = useRef(null); const [width, gridRef] = useWidth(); - const photos = snap?.photos ?? []; + const photos = useMemo(() => snap?.photos ?? [], [snap]); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); @@ -61,19 +61,15 @@ export function GalleryView() { const activeIndex = activeId ? filtered.findIndex((p) => p.id === activeId) : -1; - useEffect(() => { - if (activeId && !filtered.some((p) => p.id === activeId)) setActiveId(null); - }, [activeId, filtered]); + if (activeId && activeIndex < 0) setActiveId(null); - useEffect(() => { - setSelected((s) => { - if (s.size === 0) return s; - const live = new Set(photos.map((p) => p.id)); - const next = new Set(); - for (const id of s) if (live.has(id)) next.add(id); - return next.size === s.size ? s : next; - }); - }, [photos]); + const selected = useMemo(() => { + if (selectedRaw.size === 0) return selectedRaw; + const live = new Set(photos.map((p) => p.id)); + const next = new Set(); + for (const id of selectedRaw) if (live.has(id)) next.add(id); + return next.size === selectedRaw.size ? selectedRaw : next; + }, [selectedRaw, photos]); const selecting = selected.size > 0; const toggle = useCallback((id: string) => { @@ -128,7 +124,9 @@ export function GalleryView() { }, [activeIndex, filtered, deleteIds]); const selectedRef = useRef(selected); - selectedRef.current = selected; + useEffect(() => { + selectedRef.current = selected; + }, [selected]); const startRef = useRef<{ x: number; y: number } | null>(null); const baseRef = useRef>(new Set()); const rectsRef = useRef<{ id: string; r: DOMRect }[]>([]); diff --git a/src/renderer/src/features/gallery/useGallery.ts b/src/renderer/src/features/gallery/useGallery.ts index eee32e1..525898d 100644 --- a/src/renderer/src/features/gallery/useGallery.ts +++ b/src/renderer/src/features/gallery/useGallery.ts @@ -19,16 +19,20 @@ export function useGallery(): GalleryData { const [error, setError] = useState(null); const [recent, setRecent] = useState>(() => new Set()); - const reload = useCallback(() => { - setLoading(true); - setError(null); - api.gallery + const fetchSnapshot = useCallback(() => { + return api.gallery .snapshot() .then(setSnap) .catch((e) => setError(errorMessage(e, "Could not load your gallery."))) .finally(() => setLoading(false)); }, []); + const reload = useCallback(() => { + setLoading(true); + setError(null); + void fetchSnapshot(); + }, [fetchSnapshot]); + const remove = useCallback( async (ids: string[]) => { if (ids.length === 0) return; @@ -44,7 +48,9 @@ export function useGallery(): GalleryData { [reload], ); - useEffect(reload, [reload]); + useEffect(() => { + void fetchSnapshot(); + }, [fetchSnapshot]); useEffect(() => { return events.on("gallery:added", (photo) => { diff --git a/src/renderer/src/features/settings/SettingsView.tsx b/src/renderer/src/features/settings/SettingsView.tsx index e8c16d7..9e6867d 100644 --- a/src/renderer/src/features/settings/SettingsView.tsx +++ b/src/renderer/src/features/settings/SettingsView.tsx @@ -310,10 +310,12 @@ function GameSection() { const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [ok, setOk] = useState(null); + const [prevPath, setPrevPath] = useState(null); - useEffect(() => { + if ((config?.gamePath ?? "") !== prevPath) { + setPrevPath(config?.gamePath ?? ""); setPath(config?.gamePath ?? ""); - }, [config?.gamePath]); + } async function run(p: Promise, okMsg: string) { setBusy(true); diff --git a/src/renderer/src/lib/api.ts b/src/renderer/src/lib/api.ts index 9c762a0..0eaef94 100644 --- a/src/renderer/src/lib/api.ts +++ b/src/renderer/src/lib/api.ts @@ -48,6 +48,7 @@ export const api = { login: (username: string, password: string) => call("auth:login", { username, password }), verify2fa: (method: "totp" | "emailOtp", code: string) => call("auth:verify2fa", { method, code }), + cancel2fa: () => call("auth:cancel2fa"), logout: () => call("auth:logout"), }, accounts: { diff --git a/src/renderer/src/lib/useAsync.ts b/src/renderer/src/lib/useAsync.ts index 45468b8..63beab6 100644 --- a/src/renderer/src/lib/useAsync.ts +++ b/src/renderer/src/lib/useAsync.ts @@ -6,16 +6,25 @@ export type Async = | { status: "error"; message: string } | { status: "ready"; data: T }; +function sameDeps(a: DependencyList, b: DependencyList): boolean { + return a.length === b.length && a.every((v, i) => Object.is(v, b[i])); +} + export function useAsync( load: () => Promise, deps: DependencyList, fallback = "Something went wrong.", ): Async { const [state, setState] = useState>({ status: "loading" }); + const [prevDeps, setPrevDeps] = useState(deps); + + if (!sameDeps(prevDeps, deps)) { + setPrevDeps(deps); + setState({ status: "loading" }); + } useEffect(() => { let active = true; - setState({ status: "loading" }); load() .then((data) => active && setState({ status: "ready", data })) .catch( diff --git a/src/renderer/src/store/avatars.ts b/src/renderer/src/store/avatars.ts index 0df94ba..2de7c5a 100644 --- a/src/renderer/src/store/avatars.ts +++ b/src/renderer/src/store/avatars.ts @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { create } from "zustand"; import { useShallow } from "zustand/react/shallow"; import type { @@ -47,13 +47,15 @@ const failed = new Set(); export function useAvatar(avatarId?: string): { avatar?: Avatar; failed: boolean } { const avatar = useAvatars((s) => (avatarId ? s.avatars[avatarId] : undefined)); - if (avatarId && !avatar && !fetching.has(avatarId) && !failed.has(avatarId)) { + const missing = Boolean(avatarId) && !avatar; + useEffect(() => { + if (!missing || !avatarId || fetching.has(avatarId) || failed.has(avatarId)) return; fetching.add(avatarId); api.avatar .get(avatarId) .catch(() => failed.add(avatarId)) .finally(() => fetching.delete(avatarId)); - } + }, [missing, avatarId]); return { avatar, failed: avatarId ? failed.has(avatarId) : false }; } diff --git a/src/renderer/src/store/groups.ts b/src/renderer/src/store/groups.ts index bfd4ffc..22454b9 100644 --- a/src/renderer/src/store/groups.ts +++ b/src/renderer/src/store/groups.ts @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { create } from "zustand"; import { useShallow } from "zustand/react/shallow"; import type { Group, GroupSnapshot } from "../../../shared/types/group"; @@ -36,13 +37,15 @@ const failed = new Set(); export function useGroup(groupId?: string): Group | undefined { const group = useGroups((s) => (groupId ? s.groups[groupId] : undefined)); - if (groupId && !group?.detailed && !fetching.has(groupId) && !failed.has(groupId)) { + const missing = Boolean(groupId) && !group?.detailed; + useEffect(() => { + if (!missing || !groupId || fetching.has(groupId) || failed.has(groupId)) return; fetching.add(groupId); api.group .get(groupId) .catch(() => failed.add(groupId)) .finally(() => fetching.delete(groupId)); - } + }, [missing, groupId]); return group; } diff --git a/src/renderer/src/store/worlds.ts b/src/renderer/src/store/worlds.ts index d072223..8139dd0 100644 --- a/src/renderer/src/store/worlds.ts +++ b/src/renderer/src/store/worlds.ts @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { create } from "zustand"; import { useShallow } from "zustand/react/shallow"; import type { World, WorldSnapshot } from "../../../shared/types/world"; @@ -56,13 +57,15 @@ const failed = new Set(); export function useWorld(worldId?: string): World | undefined { const world = useWorlds((s) => (worldId ? s.worlds[worldId] : undefined)); - if (worldId && !world && !fetching.has(worldId) && !failed.has(worldId)) { + const missing = Boolean(worldId) && !world; + useEffect(() => { + if (!missing || !worldId || fetching.has(worldId) || failed.has(worldId)) return; fetching.add(worldId); api.world .get(worldId) .catch(() => failed.add(worldId)) .finally(() => fetching.delete(worldId)); - } + }, [missing, worldId]); return world; } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index e83a461..f46b4fe 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -43,6 +43,7 @@ export interface IpcRequests { "auth:status": () => IpcResult; "auth:login": (creds: LoginCredentials) => IpcResult; "auth:verify2fa": (payload: TwoFactorPayload) => IpcResult; + "auth:cancel2fa": () => IpcResult; "auth:logout": () => IpcResult; "accounts:list": () => IpcResult; diff --git a/src/shared/types/avatar.ts b/src/shared/types/avatar.ts index db901d0..7e478e4 100644 --- a/src/shared/types/avatar.ts +++ b/src/shared/types/avatar.ts @@ -29,7 +29,13 @@ export interface AvatarEdit { releaseStatus?: string; } -export type FavoriteVisibility = "private" | "friends" | "public"; +export type { + FavoriteVisibility, + FavoriteLimits, + FavoriteGroupEdit, + MoveResult, +} from "./favorites"; +import type { FavoriteVisibility, FavoriteLimits } from "./favorites"; export interface FavoriteAvatarFolder { name: string; @@ -38,21 +44,6 @@ export interface FavoriteAvatarFolder { avatarIds: string[]; } -export interface FavoriteLimits { - maxGroups: number; - maxPerGroup: number; -} - -export interface FavoriteGroupEdit { - displayName?: string; - visibility?: FavoriteVisibility; -} - -export interface MoveResult { - moved: number; - skipped: string[]; -} - export interface AvatarSnapshot { avatars: Avatar[]; mineIds: string[]; diff --git a/src/shared/types/favorites.ts b/src/shared/types/favorites.ts new file mode 100644 index 0000000..d939b24 --- /dev/null +++ b/src/shared/types/favorites.ts @@ -0,0 +1,16 @@ +export type FavoriteVisibility = "private" | "friends" | "public"; + +export interface FavoriteLimits { + maxGroups: number; + maxPerGroup: number; +} + +export interface FavoriteGroupEdit { + displayName?: string; + visibility?: FavoriteVisibility; +} + +export interface MoveResult { + moved: number; + skipped: string[]; +} diff --git a/src/shared/types/world.ts b/src/shared/types/world.ts index 57f7b04..e366a9c 100644 --- a/src/shared/types/world.ts +++ b/src/shared/types/world.ts @@ -47,7 +47,13 @@ export interface FavoriteWorldFolder { worldIds: string[]; } -export type FavoriteVisibility = "private" | "friends" | "public"; +export type { + FavoriteVisibility, + FavoriteLimits, + FavoriteGroupEdit, + MoveResult, +} from "./favorites"; +import type { FavoriteVisibility, FavoriteLimits } from "./favorites"; export interface FavoriteWorldGroup { name: string; @@ -57,21 +63,6 @@ export interface FavoriteWorldGroup { vrcPlus: boolean; } -export interface FavoriteLimits { - maxGroups: number; - maxPerGroup: number; -} - -export interface FavoriteGroupEdit { - displayName?: string; - visibility?: FavoriteVisibility; -} - -export interface MoveResult { - moved: number; - skipped: string[]; -} - export interface WorldFavoritesSnapshot { groups: FavoriteWorldGroup[]; limits: FavoriteLimits; diff --git a/tests/favoriteFolders.test.ts b/tests/favoriteFolders.test.ts new file mode 100644 index 0000000..66a0af3 --- /dev/null +++ b/tests/favoriteFolders.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { fillSlots, normalizeVisibility, orderSlots } from "../src/main/vrchat/favoriteFolders"; + +const folder = (name: string) => ({ name }); + +describe("normalizeVisibility", () => { + it("passes through known values and defaults the rest to private", () => { + expect(normalizeVisibility("friends")).toBe("friends"); + expect(normalizeVisibility("public")).toBe("public"); + expect(normalizeVisibility("private")).toBe("private"); + expect(normalizeVisibility("whatever")).toBe("private"); + }); +}); + +describe("orderSlots", () => { + it("puts custom-named folders before numbered slots, slots sorted numerically", () => { + const out = orderSlots( + [folder("worlds10"), folder("cool stuff"), folder("worlds2"), folder("worlds1")], + "worlds", + ); + expect(out.map((f) => f.name)).toEqual(["cool stuff", "worlds1", "worlds2", "worlds10"]); + }); +}); + +describe("fillSlots", () => { + it("pads with empty numbered slots up to the cap, skipping taken names", () => { + const out = fillSlots([folder("worlds2")], "worlds", 3, folder); + expect(out.map((f) => f.name)).toEqual(["worlds2", "worlds1", "worlds3"]); + }); + + it("does not pad past the cap when custom folders use up slots", () => { + const out = fillSlots([folder("a"), folder("b")], "worlds", 2, folder); + expect(out.map((f) => f.name)).toEqual(["a", "b"]); + }); +}); diff --git a/tests/favoriteMove.test.ts b/tests/favoriteMove.test.ts new file mode 100644 index 0000000..f829151 --- /dev/null +++ b/tests/favoriteMove.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + moveOneFavorite, + moveManyFavorites, + type FavoriteMover, +} from "../src/main/vrchat/favoriteMove"; + +interface Rec { + id: string; + favoriteId: string; + tags: string[]; +} + +function mover(overrides: Partial> = {}) { + const m = { + skip: vi.fn((rec: Rec | undefined, folder: string) => rec?.tags.includes(folder) ?? false), + canRefavorite: vi.fn(async () => true), + remove: vi.fn(async () => {}), + add: vi.fn(async () => {}), + restore: vi.fn(async () => {}), + reload: vi.fn(async () => {}), + onMoved: vi.fn(), + ...overrides, + }; + return m; +} + +const rec = (id: string, tags: string[]): Rec => ({ id: `fav-${id}`, favoriteId: id, tags }); + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +describe("moveOneFavorite", () => { + it("moves: remove, add, reload, notify", async () => { + const m = mover(); + const result = await moveOneFavorite(m, rec("w1", ["old"]), "w1", "new", true); + expect(result).toEqual({ moved: 1, skipped: [] }); + expect(m.remove).toHaveBeenCalledOnce(); + expect(m.add).toHaveBeenCalledWith("w1", "new"); + expect(m.reload).toHaveBeenCalledOnce(); + expect(m.onMoved).toHaveBeenCalledWith("w1", "new"); + }); + + it("no-ops when already in the folder", async () => { + const m = mover(); + const result = await moveOneFavorite(m, rec("w1", ["new"]), "w1", "new", true); + expect(result).toEqual({ moved: 0, skipped: [] }); + expect(m.remove).not.toHaveBeenCalled(); + }); + + it("skips without removing when the item can't be re-favorited", async () => { + const m = mover({ canRefavorite: vi.fn(async () => false) }); + const result = await moveOneFavorite(m, rec("w1", ["old"]), "w1", "new", true); + expect(result).toEqual({ moved: 0, skipped: ["w1"] }); + expect(m.remove).not.toHaveBeenCalled(); + }); + + it("restores the old favorite when add fails", async () => { + const m = mover({ add: vi.fn(async () => Promise.reject({ status: 400 })) }); + const old = rec("w1", ["old"]); + const result = await moveOneFavorite(m, old, "w1", "new", true); + expect(result).toEqual({ moved: 0, skipped: ["w1"] }); + expect(m.restore).toHaveBeenCalledWith(old); + expect(m.onMoved).not.toHaveBeenCalled(); + }); + + it("rethrows transient failures after restoring", async () => { + const m = mover({ add: vi.fn(async () => Promise.reject({ status: 429 })) }); + await expect(moveOneFavorite(m, rec("w1", ["old"]), "w1", "new", true)).rejects.toMatchObject({ + status: 429, + }); + expect(m.restore).toHaveBeenCalledOnce(); + }); +}); + +describe("moveManyFavorites", () => { + it("moves everything movable and reports the rest as skipped", async () => { + const m = mover({ + canRefavorite: vi.fn(async (id: string) => id !== "w3"), + }); + const records = new Map([ + ["w1", rec("w1", ["old"])], + ["w2", rec("w2", ["new"])], + ["w3", rec("w3", ["old"])], + ]); + + const done = moveManyFavorites(m, records, ["w1", "w2", "w3", "w4"], "new"); + await vi.runAllTimersAsync(); + const result = await done; + + expect(result).toEqual({ moved: 2, skipped: ["w3"] }); + expect(m.add).toHaveBeenCalledWith("w1", "new"); + expect(m.add).toHaveBeenCalledWith("w4", "new"); + expect(m.reload).toHaveBeenCalledOnce(); + }); + + it("aborts on a transient failure after restoring the current item", async () => { + const m = mover({ + add: vi.fn(async (id: string) => { + if (id === "w2") throw { status: 500 }; + }), + }); + const records = new Map([ + ["w1", rec("w1", ["old"])], + ["w2", rec("w2", ["old"])], + ]); + + const done = moveManyFavorites(m, records, ["w1", "w2"], "new"); + done.catch(() => {}); + await vi.runAllTimersAsync(); + + await expect(done).rejects.toMatchObject({ status: 500 }); + expect(m.restore).toHaveBeenCalledWith(records.get("w2")); + expect(m.reload).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/repository.test.ts b/tests/repository.test.ts new file mode 100644 index 0000000..1ae73f2 --- /dev/null +++ b/tests/repository.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Repository } from "../src/main/store/repository/repository"; +import type { StorageBackend } from "../src/main/store/repository/backend"; +import type { StoredEntity } from "../src/shared/types/repository"; +import type { FieldPolicy } from "../src/main/store/repository/fieldPolicy"; + +interface Thing { + id: string; + name: string; + bio?: string; + occupants?: number; +} + +function memoryBackend(): StorageBackend { + const map = new Map>(); + return { + load: () => new Map(map), + put: (id, e) => void map.set(id, e), + remove: (id) => void map.delete(id), + flush: () => {}, + clear: () => map.clear(), + file: null, + }; +} + +const policy: FieldPolicy = { + classOf: (f) => (f === "occupants" || f === "bio" ? "live" : "identity"), + maxAge: { identity: 60_000, stat: 60_000, live: 1000 }, + keepNonEmpty: new Set(["bio"]), +}; + +function makeRepo() { + return new Repository({ name: "things", policy, backend: memoryBackend() }); +} + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +describe("Repository.upsert — source priority on live fields", () => { + it("a lower-priority source cannot overwrite a live field set by ws", () => { + const repo = makeRepo(); + repo.upsert({ id: "a", name: "A", occupants: 5 }, "ws"); + vi.advanceTimersByTime(10); + repo.upsert({ id: "a", occupants: 1 }, "rest:list"); + expect(repo.get("a")?.occupants).toBe(5); + }); + + it("equal-priority live writes apply when newer", () => { + const repo = makeRepo(); + repo.upsert({ id: "a", name: "A", occupants: 5 }, "ws"); + vi.advanceTimersByTime(10); + repo.upsert({ id: "a", occupants: 9 }, "ws"); + expect(repo.get("a")?.occupants).toBe(9); + }); +}); + +describe("Repository.upsert — timestamp ordering", () => { + it("ignores writes older than the stored field", () => { + const repo = makeRepo(); + const now = Date.now(); + repo.upsert({ id: "a", name: "new" }, "rest:detail", now); + repo.upsert({ id: "a", name: "old" }, "rest:detail", now - 1000); + expect(repo.get("a")?.name).toBe("new"); + }); +}); + +describe("Repository.upsert — empty-value guards", () => { + it("an empty identity value never wipes a resolved one", () => { + const repo = makeRepo(); + repo.upsert({ id: "a", name: "Resolved" }, "rest:detail"); + vi.advanceTimersByTime(10); + repo.upsert({ id: "a", name: "" }, "rest:detail"); + expect(repo.get("a")?.name).toBe("Resolved"); + }); + + it("keepNonEmpty fields survive empty writes from lower-priority sources", () => { + const repo = makeRepo(); + repo.upsert({ id: "a", name: "A", bio: "hello" }, "rest:detail"); + vi.advanceTimersByTime(10); + repo.upsert({ id: "a", bio: "" }, "rest:list"); + expect(repo.get("a")?.bio).toBe("hello"); + }); + + it("a rest:detail read may clear a keepNonEmpty field", () => { + const repo = makeRepo(); + repo.upsert({ id: "a", name: "A", bio: "hello" }, "rest:detail"); + vi.advanceTimersByTime(10); + repo.upsert({ id: "a", bio: "" }, "rest:detail"); + expect(repo.get("a")?.bio).toBe(""); + }); +}); + +describe("Repository.upsert — change notification", () => { + it("emits on real changes and stays silent on no-op writes", () => { + const repo = makeRepo(); + const seen = vi.fn(); + repo.onChange(seen); + + repo.upsert({ id: "a", name: "A" }, "rest:detail"); + expect(seen).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(10); + repo.upsert({ id: "a", name: "" }, "rest:detail"); + expect(seen).toHaveBeenCalledTimes(1); + }); +}); + +describe("Repository.isStale", () => { + it("reports live fields stale after their max age and identity fields fresh", () => { + const repo = makeRepo(); + repo.upsert({ id: "a", name: "A", occupants: 3 }, "ws"); + vi.advanceTimersByTime(2000); + expect(repo.isStale("a", "occupants")).toBe(true); + expect(repo.isStale("a", "name")).toBe(false); + }); +});