From c8317eb02af1b2834106fc34ac3ff7f98c3567f4 Mon Sep 17 00:00:00 2001 From: Yuzu Date: Thu, 25 Jun 2026 19:25:00 +0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20Initial=20commit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 + .prettierignore | 7 + .prettierrc.json | 6 + README.md | 0 electron-builder.yml | 16 + electron.vite.config.ts | 25 + eslint.config.js | 47 + package.json | 60 + pnpm-lock.yaml | 5857 +++++++++++++++++ pnpm-workspace.yaml | 4 + src/main/accounts/store.ts | 67 + src/main/cache/cache.ts | 302 + src/main/cache/policies.ts | 32 + src/main/config/appConfig.ts | 32 + src/main/debug/bridge.ts | 22 + src/main/debug/logger.ts | 31 + src/main/debug/service.ts | 49 + src/main/debug/wsLog.ts | 22 + src/main/enhancements/screenshotSymlink.ts | 112 + src/main/enhancements/service.ts | 69 + src/main/gallery/metadata.ts | 69 + src/main/gallery/paths.ts | 36 + src/main/gallery/protocol.ts | 69 + src/main/gallery/service.ts | 103 + src/main/gallery/thumbnails.ts | 102 + src/main/gallery/watcher.ts | 95 + src/main/game/launch.ts | 62 + src/main/game/steam.ts | 62 + src/main/index.ts | 71 + src/main/ipc/handlers.ts | 127 + src/main/lib/atomicFile.ts | 33 + src/main/store/entityStore.ts | 88 + src/main/store/repository/backend.ts | 113 + src/main/store/repository/fieldPolicy.ts | 84 + src/main/store/repository/manager.ts | 128 + src/main/store/repository/repository.ts | 289 + src/main/store/social.ts | 207 + src/main/store/worldStore.ts | 68 + src/main/vrchat/authService.ts | 181 + src/main/vrchat/avatarService.ts | 27 + src/main/vrchat/cachedRead.ts | 13 + src/main/vrchat/client.ts | 68 + src/main/vrchat/cookies.ts | 50 + src/main/vrchat/errors.ts | 59 + src/main/vrchat/friendsService.ts | 37 + src/main/vrchat/groupService.ts | 18 + src/main/vrchat/mappers.ts | 256 + src/main/vrchat/rawEndpoints.ts | 39 + src/main/vrchat/settingsService.ts | 208 + src/main/vrchat/userService.ts | 65 + src/main/vrchat/worldService.ts | 148 + src/main/windows.ts | 98 + src/preload/index.d.ts | 9 + src/preload/index.ts | 32 + src/renderer/index.html | 16 + src/renderer/src/App.tsx | 13 + src/renderer/src/components/AppShell.tsx | 204 + src/renderer/src/components/ui/Avatar.tsx | 27 + src/renderer/src/components/ui/Badge.tsx | 27 + src/renderer/src/components/ui/Banner.tsx | 16 + src/renderer/src/components/ui/Button.tsx | 60 + .../src/components/ui/CollapsibleCard.tsx | 33 + src/renderer/src/components/ui/Field.tsx | 19 + src/renderer/src/components/ui/LinkPill.tsx | 14 + src/renderer/src/components/ui/Loader.tsx | 11 + src/renderer/src/components/ui/Modal.tsx | 97 + src/renderer/src/components/ui/Panel.tsx | 28 + .../src/components/ui/PresenceAvatar.tsx | 21 + src/renderer/src/components/ui/Section.tsx | 49 + .../src/components/ui/SkeletonGrid.tsx | 17 + src/renderer/src/components/ui/Stat.tsx | 23 + src/renderer/src/components/ui/StatTile.tsx | 27 + src/renderer/src/components/ui/StatusDot.tsx | 22 + src/renderer/src/components/ui/Tabs.tsx | 33 + src/renderer/src/components/ui/Tag.tsx | 17 + src/renderer/src/components/ui/Toggle.tsx | 38 + src/renderer/src/components/ui/index.ts | 19 + .../features/account/AccountSettingsView.tsx | 91 + .../account/sections/AccountLinksSection.tsx | 41 + .../sections/AgeVerificationSection.tsx | 30 + .../account/sections/ContentGatingSection.tsx | 48 + .../account/sections/DangerZoneSection.tsx | 57 + .../account/sections/DisplayNameSection.tsx | 198 + .../account/sections/EmailSection.tsx | 64 + .../account/sections/PasswordSection.tsx | 80 + .../account/sections/PrivacySection.tsx | 38 + .../account/sections/TwoFactorSection.tsx | 190 + .../account/sections/UserDataSection.tsx | 39 + src/renderer/src/features/account/ui.tsx | 156 + .../features/account/useAccountSettings.ts | 43 + .../src/features/auth/AccountSwitcher.tsx | 217 + .../src/features/auth/AuthContext.tsx | 143 + .../src/features/auth/LoginScreen.tsx | 195 + .../src/features/auth/TwoFactorPrompt.tsx | 64 + src/renderer/src/features/auth/useStepUp.ts | 58 + .../src/features/debug/DebugPanel.tsx | 93 + .../src/features/debug/DebugWindow.tsx | 9 + .../src/features/debug/tabs/CacheTab.tsx | 232 + .../src/features/debug/tabs/LogsTab.tsx | 74 + .../src/features/debug/tabs/ReposTab.tsx | 273 + .../src/features/debug/tabs/SocialTab.tsx | 129 + .../src/features/debug/tabs/ThumbnailsTab.tsx | 58 + .../src/features/debug/tabs/WebSocketTab.tsx | 83 + .../src/features/debug/tabs/WorldsTab.tsx | 90 + src/renderer/src/features/debug/ui.tsx | 155 + src/renderer/src/features/debug/useDebug.ts | 85 + .../enhancements/EnhancementsView.tsx | 158 + .../src/features/friends/FriendsSidebar.tsx | 103 + .../src/features/gallery/GalleryView.tsx | 508 ++ .../src/features/gallery/Lightbox.tsx | 200 + .../src/features/gallery/Timeline.tsx | 66 + src/renderer/src/features/gallery/format.ts | 19 + src/renderer/src/features/gallery/gallery.css | 637 ++ src/renderer/src/features/gallery/justify.ts | 47 + .../src/features/gallery/useGallery.ts | 69 + .../src/features/game/LaunchButton.tsx | 52 + .../src/features/navigation/NavContext.tsx | 69 + .../src/features/profile/GroupsSection.tsx | 107 + .../src/features/profile/LocationSection.tsx | 97 + .../src/features/profile/ProfileView.tsx | 293 + .../src/features/profile/WorldsSection.tsx | 152 + src/renderer/src/features/profile/profile.css | 82 + .../src/features/profile/useFavoriteWorlds.ts | 57 + .../src/features/profile/useProfile.ts | 26 + .../src/features/profile/useUserGroups.ts | 31 + .../src/features/profile/useUserWorlds.ts | 18 + .../src/features/search/SearchView.tsx | 157 + .../src/features/settings/SettingsView.tsx | 372 ++ src/renderer/src/features/world/WorldView.tsx | 176 + src/renderer/src/lib/ThemeContext.tsx | 121 + src/renderer/src/lib/api.ts | 127 + src/renderer/src/lib/format.ts | 37 + src/renderer/src/lib/i18n/I18nContext.tsx | 35 + src/renderer/src/lib/i18n/index.ts | 11 + .../src/lib/i18n/locales/en/account.json | 148 + .../src/lib/i18n/locales/en/auth.json | 22 + .../src/lib/i18n/locales/en/common.json | 5 + .../src/lib/i18n/locales/en/enhancements.json | 18 + .../src/lib/i18n/locales/en/gallery.json | 34 + .../src/lib/i18n/locales/en/game.json | 6 + src/renderer/src/lib/i18n/locales/en/nav.json | 11 + .../src/lib/i18n/locales/en/settings.json | 62 + .../src/lib/i18n/locales/ja/account.json | 148 + .../src/lib/i18n/locales/ja/auth.json | 22 + .../src/lib/i18n/locales/ja/common.json | 5 + .../src/lib/i18n/locales/ja/enhancements.json | 18 + .../src/lib/i18n/locales/ja/gallery.json | 32 + .../src/lib/i18n/locales/ja/game.json | 6 + src/renderer/src/lib/i18n/locales/ja/nav.json | 11 + .../src/lib/i18n/locales/ja/settings.json | 62 + .../src/lib/i18n/locales/th/account.json | 148 + .../src/lib/i18n/locales/th/auth.json | 22 + .../src/lib/i18n/locales/th/common.json | 5 + .../src/lib/i18n/locales/th/enhancements.json | 18 + .../src/lib/i18n/locales/th/gallery.json | 32 + .../src/lib/i18n/locales/th/game.json | 6 + src/renderer/src/lib/i18n/locales/th/nav.json | 11 + .../src/lib/i18n/locales/th/settings.json | 62 + src/renderer/src/lib/i18n/registry.ts | 71 + src/renderer/src/lib/i18n/types.ts | 14 + src/renderer/src/lib/layout.ts | 3 + src/renderer/src/lib/theme.ts | 203 + src/renderer/src/lib/useAsync.ts | 32 + src/renderer/src/lib/vrchat.ts | 112 + src/renderer/src/main.tsx | 34 + src/renderer/src/store/social.ts | 31 + src/renderer/src/store/worlds.ts | 55 + src/renderer/src/styles/app-shell.css | 265 + src/renderer/src/styles/boot.css | 21 + src/renderer/src/styles/global.css | 155 + src/renderer/src/vite-env.d.ts | 1 + src/shared/ipc.ts | 131 + src/shared/types/appConfig.ts | 4 + src/shared/types/auth.ts | 34 + src/shared/types/avatar.ts | 14 + src/shared/types/debug.ts | 59 + src/shared/types/enhancements.ts | 20 + src/shared/types/gallery.ts | 32 + src/shared/types/game.ts | 4 + src/shared/types/group.ts | 12 + src/shared/types/repository.ts | 28 + src/shared/types/result.ts | 17 + src/shared/types/settings.ts | 46 + src/shared/types/user.ts | 83 + src/shared/types/world.ts | 47 + src/shared/window.ts | 6 + tsconfig.json | 4 + tsconfig.node.json | 20 + tsconfig.web.json | 23 + 189 files changed, 20068 insertions(+) create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 README.md create mode 100644 electron-builder.yml create mode 100644 electron.vite.config.ts create mode 100644 eslint.config.js create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 src/main/accounts/store.ts create mode 100644 src/main/cache/cache.ts create mode 100644 src/main/cache/policies.ts create mode 100644 src/main/config/appConfig.ts create mode 100644 src/main/debug/bridge.ts create mode 100644 src/main/debug/logger.ts create mode 100644 src/main/debug/service.ts create mode 100644 src/main/debug/wsLog.ts create mode 100644 src/main/enhancements/screenshotSymlink.ts create mode 100644 src/main/enhancements/service.ts create mode 100644 src/main/gallery/metadata.ts create mode 100644 src/main/gallery/paths.ts create mode 100644 src/main/gallery/protocol.ts create mode 100644 src/main/gallery/service.ts create mode 100644 src/main/gallery/thumbnails.ts create mode 100644 src/main/gallery/watcher.ts create mode 100644 src/main/game/launch.ts create mode 100644 src/main/game/steam.ts create mode 100644 src/main/index.ts create mode 100644 src/main/ipc/handlers.ts create mode 100644 src/main/lib/atomicFile.ts create mode 100644 src/main/store/entityStore.ts create mode 100644 src/main/store/repository/backend.ts create mode 100644 src/main/store/repository/fieldPolicy.ts create mode 100644 src/main/store/repository/manager.ts create mode 100644 src/main/store/repository/repository.ts create mode 100644 src/main/store/social.ts create mode 100644 src/main/store/worldStore.ts create mode 100644 src/main/vrchat/authService.ts create mode 100644 src/main/vrchat/avatarService.ts create mode 100644 src/main/vrchat/cachedRead.ts create mode 100644 src/main/vrchat/client.ts create mode 100644 src/main/vrchat/cookies.ts create mode 100644 src/main/vrchat/errors.ts create mode 100644 src/main/vrchat/friendsService.ts create mode 100644 src/main/vrchat/groupService.ts create mode 100644 src/main/vrchat/mappers.ts create mode 100644 src/main/vrchat/rawEndpoints.ts create mode 100644 src/main/vrchat/settingsService.ts create mode 100644 src/main/vrchat/userService.ts create mode 100644 src/main/vrchat/worldService.ts create mode 100644 src/main/windows.ts create mode 100644 src/preload/index.d.ts create mode 100644 src/preload/index.ts create mode 100644 src/renderer/index.html create mode 100644 src/renderer/src/App.tsx create mode 100644 src/renderer/src/components/AppShell.tsx create mode 100644 src/renderer/src/components/ui/Avatar.tsx create mode 100644 src/renderer/src/components/ui/Badge.tsx create mode 100644 src/renderer/src/components/ui/Banner.tsx create mode 100644 src/renderer/src/components/ui/Button.tsx create mode 100644 src/renderer/src/components/ui/CollapsibleCard.tsx create mode 100644 src/renderer/src/components/ui/Field.tsx create mode 100644 src/renderer/src/components/ui/LinkPill.tsx create mode 100644 src/renderer/src/components/ui/Loader.tsx create mode 100644 src/renderer/src/components/ui/Modal.tsx create mode 100644 src/renderer/src/components/ui/Panel.tsx create mode 100644 src/renderer/src/components/ui/PresenceAvatar.tsx create mode 100644 src/renderer/src/components/ui/Section.tsx create mode 100644 src/renderer/src/components/ui/SkeletonGrid.tsx create mode 100644 src/renderer/src/components/ui/Stat.tsx create mode 100644 src/renderer/src/components/ui/StatTile.tsx create mode 100644 src/renderer/src/components/ui/StatusDot.tsx create mode 100644 src/renderer/src/components/ui/Tabs.tsx create mode 100644 src/renderer/src/components/ui/Tag.tsx create mode 100644 src/renderer/src/components/ui/Toggle.tsx create mode 100644 src/renderer/src/components/ui/index.ts create mode 100644 src/renderer/src/features/account/AccountSettingsView.tsx create mode 100644 src/renderer/src/features/account/sections/AccountLinksSection.tsx create mode 100644 src/renderer/src/features/account/sections/AgeVerificationSection.tsx create mode 100644 src/renderer/src/features/account/sections/ContentGatingSection.tsx create mode 100644 src/renderer/src/features/account/sections/DangerZoneSection.tsx create mode 100644 src/renderer/src/features/account/sections/DisplayNameSection.tsx create mode 100644 src/renderer/src/features/account/sections/EmailSection.tsx create mode 100644 src/renderer/src/features/account/sections/PasswordSection.tsx create mode 100644 src/renderer/src/features/account/sections/PrivacySection.tsx create mode 100644 src/renderer/src/features/account/sections/TwoFactorSection.tsx create mode 100644 src/renderer/src/features/account/sections/UserDataSection.tsx create mode 100644 src/renderer/src/features/account/ui.tsx create mode 100644 src/renderer/src/features/account/useAccountSettings.ts create mode 100644 src/renderer/src/features/auth/AccountSwitcher.tsx create mode 100644 src/renderer/src/features/auth/AuthContext.tsx create mode 100644 src/renderer/src/features/auth/LoginScreen.tsx create mode 100644 src/renderer/src/features/auth/TwoFactorPrompt.tsx create mode 100644 src/renderer/src/features/auth/useStepUp.ts create mode 100644 src/renderer/src/features/debug/DebugPanel.tsx create mode 100644 src/renderer/src/features/debug/DebugWindow.tsx create mode 100644 src/renderer/src/features/debug/tabs/CacheTab.tsx create mode 100644 src/renderer/src/features/debug/tabs/LogsTab.tsx create mode 100644 src/renderer/src/features/debug/tabs/ReposTab.tsx create mode 100644 src/renderer/src/features/debug/tabs/SocialTab.tsx create mode 100644 src/renderer/src/features/debug/tabs/ThumbnailsTab.tsx create mode 100644 src/renderer/src/features/debug/tabs/WebSocketTab.tsx create mode 100644 src/renderer/src/features/debug/tabs/WorldsTab.tsx create mode 100644 src/renderer/src/features/debug/ui.tsx create mode 100644 src/renderer/src/features/debug/useDebug.ts create mode 100644 src/renderer/src/features/enhancements/EnhancementsView.tsx create mode 100644 src/renderer/src/features/friends/FriendsSidebar.tsx create mode 100644 src/renderer/src/features/gallery/GalleryView.tsx create mode 100644 src/renderer/src/features/gallery/Lightbox.tsx create mode 100644 src/renderer/src/features/gallery/Timeline.tsx create mode 100644 src/renderer/src/features/gallery/format.ts create mode 100644 src/renderer/src/features/gallery/gallery.css create mode 100644 src/renderer/src/features/gallery/justify.ts create mode 100644 src/renderer/src/features/gallery/useGallery.ts create mode 100644 src/renderer/src/features/game/LaunchButton.tsx create mode 100644 src/renderer/src/features/navigation/NavContext.tsx create mode 100644 src/renderer/src/features/profile/GroupsSection.tsx create mode 100644 src/renderer/src/features/profile/LocationSection.tsx create mode 100644 src/renderer/src/features/profile/ProfileView.tsx create mode 100644 src/renderer/src/features/profile/WorldsSection.tsx create mode 100644 src/renderer/src/features/profile/profile.css create mode 100644 src/renderer/src/features/profile/useFavoriteWorlds.ts create mode 100644 src/renderer/src/features/profile/useProfile.ts create mode 100644 src/renderer/src/features/profile/useUserGroups.ts create mode 100644 src/renderer/src/features/profile/useUserWorlds.ts create mode 100644 src/renderer/src/features/search/SearchView.tsx create mode 100644 src/renderer/src/features/settings/SettingsView.tsx create mode 100644 src/renderer/src/features/world/WorldView.tsx create mode 100644 src/renderer/src/lib/ThemeContext.tsx create mode 100644 src/renderer/src/lib/api.ts create mode 100644 src/renderer/src/lib/format.ts create mode 100644 src/renderer/src/lib/i18n/I18nContext.tsx create mode 100644 src/renderer/src/lib/i18n/index.ts create mode 100644 src/renderer/src/lib/i18n/locales/en/account.json create mode 100644 src/renderer/src/lib/i18n/locales/en/auth.json create mode 100644 src/renderer/src/lib/i18n/locales/en/common.json create mode 100644 src/renderer/src/lib/i18n/locales/en/enhancements.json create mode 100644 src/renderer/src/lib/i18n/locales/en/gallery.json create mode 100644 src/renderer/src/lib/i18n/locales/en/game.json create mode 100644 src/renderer/src/lib/i18n/locales/en/nav.json create mode 100644 src/renderer/src/lib/i18n/locales/en/settings.json create mode 100644 src/renderer/src/lib/i18n/locales/ja/account.json create mode 100644 src/renderer/src/lib/i18n/locales/ja/auth.json create mode 100644 src/renderer/src/lib/i18n/locales/ja/common.json create mode 100644 src/renderer/src/lib/i18n/locales/ja/enhancements.json create mode 100644 src/renderer/src/lib/i18n/locales/ja/gallery.json create mode 100644 src/renderer/src/lib/i18n/locales/ja/game.json create mode 100644 src/renderer/src/lib/i18n/locales/ja/nav.json create mode 100644 src/renderer/src/lib/i18n/locales/ja/settings.json create mode 100644 src/renderer/src/lib/i18n/locales/th/account.json create mode 100644 src/renderer/src/lib/i18n/locales/th/auth.json create mode 100644 src/renderer/src/lib/i18n/locales/th/common.json create mode 100644 src/renderer/src/lib/i18n/locales/th/enhancements.json create mode 100644 src/renderer/src/lib/i18n/locales/th/gallery.json create mode 100644 src/renderer/src/lib/i18n/locales/th/game.json create mode 100644 src/renderer/src/lib/i18n/locales/th/nav.json create mode 100644 src/renderer/src/lib/i18n/locales/th/settings.json create mode 100644 src/renderer/src/lib/i18n/registry.ts create mode 100644 src/renderer/src/lib/i18n/types.ts create mode 100644 src/renderer/src/lib/layout.ts create mode 100644 src/renderer/src/lib/theme.ts create mode 100644 src/renderer/src/lib/useAsync.ts create mode 100644 src/renderer/src/lib/vrchat.ts create mode 100644 src/renderer/src/main.tsx create mode 100644 src/renderer/src/store/social.ts create mode 100644 src/renderer/src/store/worlds.ts create mode 100644 src/renderer/src/styles/app-shell.css create mode 100644 src/renderer/src/styles/boot.css create mode 100644 src/renderer/src/styles/global.css create mode 100644 src/renderer/src/vite-env.d.ts create mode 100644 src/shared/ipc.ts create mode 100644 src/shared/types/appConfig.ts create mode 100644 src/shared/types/auth.ts create mode 100644 src/shared/types/avatar.ts create mode 100644 src/shared/types/debug.ts create mode 100644 src/shared/types/enhancements.ts create mode 100644 src/shared/types/gallery.ts create mode 100644 src/shared/types/game.ts create mode 100644 src/shared/types/group.ts create mode 100644 src/shared/types/repository.ts create mode 100644 src/shared/types/result.ts create mode 100644 src/shared/types/settings.ts create mode 100644 src/shared/types/user.ts create mode 100644 src/shared/types/world.ts create mode 100644 src/shared/window.ts create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 tsconfig.web.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e24256b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +out/ +dist/ +*.log +*.tsbuildinfo +.DS_Store +.env +.env.* diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..e863c6c --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +out +dist +node_modules +release +coverage +*.log +pnpm-lock.yaml diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..90abee2 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "printWidth": 100, + "semi": true, + "singleQuote": false, + "trailingComma": "all" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/electron-builder.yml b/electron-builder.yml new file mode 100644 index 0000000..d2be98d --- /dev/null +++ b/electron-builder.yml @@ -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 diff --git a/electron.vite.config.ts b/electron.vite.config.ts new file mode 100644 index 0000000..368b9d8 --- /dev/null +++ b/electron.vite.config.ts @@ -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") }, + }, + }, +}); diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..0c6337b --- /dev/null +++ b/eslint.config.js @@ -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" }, + ], + }, + ], + }, + }, +); diff --git a/package.json b/package.json new file mode 100644 index 0000000..a51de8c --- /dev/null +++ b/package.json @@ -0,0 +1,60 @@ +{ + "name": "vrc-circle", + "version": "0.1.0", + "description": "An all-in-one VRChat Hub & Launcher", + "author": "YuzuZensai ", + "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" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..049fbef --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,5857 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + electron-updater: + specifier: ^6.3.9 + version: 6.8.9 + i18next: + specifier: ^26.3.2 + version: 26.3.2(typescript@5.9.3) + keyv: + specifier: ^5.2.3 + version: 5.6.0 + keyv-file: + specifier: ^5.1.2 + version: 5.3.4 + lucide-react: + specifier: ^1.21.0 + version: 1.21.0(react@19.2.7) + react-i18next: + specifier: ^17.0.8 + version: 17.0.8(i18next@26.3.2(typescript@5.9.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.9.3) + sharp: + specifier: ^0.35.2 + version: 0.35.2 + vrchat: + specifier: ^2.1.0 + version: 2.21.7 + ws: + specifier: ^8.18.0 + version: 8.21.0 + zustand: + specifier: ^5.0.14 + version: 5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)) + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.6.0(jiti@2.7.0)) + '@tailwindcss/vite': + specifier: ^4.3.1 + version: 4.3.1(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)) + '@types/node': + specifier: ^22.10.5 + version: 22.20.0 + '@types/react': + specifier: ^19.0.4 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.0.2 + version: 19.2.3(@types/react@19.2.17) + '@types/ws': + specifier: ^8.5.13 + version: 8.18.1 + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)) + electron: + specifier: ^33.3.0 + version: 33.4.11 + electron-builder: + specifier: ^25.1.8 + version: 25.1.8(electron-builder-squirrel-windows@25.1.8) + electron-vite: + specifier: ^2.3.0 + version: 2.3.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)) + eslint: + specifier: ^10.6.0 + version: 10.6.0(jiti@2.7.0) + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.6.0(jiti@2.7.0)) + eslint-plugin-react-refresh: + specifier: ^0.5.3 + version: 0.5.3(eslint@10.6.0(jiti@2.7.0)) + globals: + specifier: ^17.7.0 + version: 17.7.0 + playwright-core: + specifier: ^1.61.1 + version: 1.61.1 + prettier: + specifier: ^3.6.2 + version: 3.8.4 + react: + specifier: ^19.0.0 + version: 19.2.7 + react-dom: + specifier: ^19.0.0 + version: 19.2.7(react@19.2.7) + tailwindcss: + specifier: ^4.3.1 + version: 4.3.1 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.62.0 + version: 8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@5.9.3) + vite: + specifier: ^6.0.7 + version: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0) + +packages: + + 7zip-bin@5.2.0: + resolution: {integrity: sha512-ukTPVhqG4jNzMro2qA9HSCSSVJN3aN7tlb+hfqYCt3ER0yWroeA2VR38MNrOHLQ/cVj+DaIMad0kFCtWWowh/A==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-arrow-functions@7.29.7': + resolution: {integrity: sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@develar/schema-utils@2.6.5': + resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} + engines: {node: '>= 8.9.0'} + + '@electron/asar@3.4.1': + resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} + engines: {node: '>=10.12.0'} + hasBin: true + + '@electron/get@2.0.3': + resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} + engines: {node: '>=12'} + + '@electron/notarize@2.5.0': + resolution: {integrity: sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==} + engines: {node: '>= 10.0.0'} + + '@electron/osx-sign@1.3.1': + resolution: {integrity: sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw==} + engines: {node: '>=12.0.0'} + hasBin: true + + '@electron/rebuild@3.6.1': + resolution: {integrity: sha512-f6596ZHpEq/YskUd8emYvOUne89ij8mQgjYFA5ru25QwbrRO+t1SImofdDv7kKOuWCmVOuU5tvfkbgGxIl3E/w==} + engines: {node: '>=12.13.0'} + hasBin: true + + '@electron/universal@2.0.1': + resolution: {integrity: sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==} + engines: {node: '>=16.4'} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@gar/promisify@1.1.3': + resolution: {integrity: sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@malept/cross-spawn-promise@2.0.0': + resolution: {integrity: sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==} + engines: {node: '>= 12.13.0'} + + '@malept/flatpak-bundler@0.4.0': + resolution: {integrity: sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==} + engines: {node: '>= 10.0.0'} + + '@npmcli/fs@2.1.2': + resolution: {integrity: sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + '@npmcli/move-file@2.0.1': + resolution: {integrity: sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This functionality has been moved to @npmcli/fs + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@sindresorhus/is@4.6.0': + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + + '@szmarczak/http-timer@4.0.6': + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + + '@tailwindcss/node@4.3.1': + resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==} + + '@tailwindcss/oxide-android-arm64@4.3.1': + resolution: {integrity: sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.1': + resolution: {integrity: sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.1': + resolution: {integrity: sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.1': + resolution: {integrity: sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': + resolution: {integrity: sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': + resolution: {integrity: sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.1': + resolution: {integrity: sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.1': + resolution: {integrity: sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.1': + resolution: {integrity: sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.1': + resolution: {integrity: sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': + resolution: {integrity: sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.1': + resolution: {integrity: sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.1': + resolution: {integrity: sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.1': + resolution: {integrity: sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tootallnate/once@2.0.1': + resolution: {integrity: sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==} + engines: {node: '>= 10'} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/cacheable-request@6.0.3': + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/fs-extra@9.0.13': + resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + + '@types/http-cache-semantics@4.2.0': + resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/keyv@3.1.4': + resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@20.19.43': + resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + + '@types/node@22.20.0': + resolution: {integrity: sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==} + + '@types/plist@3.0.5': + resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/responselike@1.0.3': + resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + + '@types/verror@1.10.11': + resolution: {integrity: sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@types/yauzl@2.10.3': + resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==} + + '@typescript-eslint/eslint-plugin@8.62.0': + resolution: {integrity: sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.62.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.62.0': + resolution: {integrity: sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.62.0': + resolution: {integrity: sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.62.0': + resolution: {integrity: sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.62.0': + resolution: {integrity: sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.62.0': + resolution: {integrity: sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.62.0': + resolution: {integrity: sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.62.0': + resolution: {integrity: sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.62.0': + resolution: {integrity: sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.62.0': + resolution: {integrity: sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@xmldom/xmldom@0.9.10': + resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} + engines: {node: '>=14.6'} + + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + + aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + + ajv-keywords@3.5.2: + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + app-builder-bin@5.0.0-alpha.10: + resolution: {integrity: sha512-Ev4jj3D7Bo+O0GPD2NMvJl+PGiBAfS7pUGawntBNpCbxtpncfUixqFj9z9Jme7V7s3LBGqsWZZP54fxBX3JKJw==} + + app-builder-lib@25.1.8: + resolution: {integrity: sha512-pCqe7dfsQFBABC1jeKZXQWhGcCPF3rPCXDdfqVKjIeWBcXzyC1iOWZdfFhGl+S9MyE/k//DFmC6FzuGAUudNDg==} + engines: {node: '>=14.0.0'} + peerDependencies: + dmg-builder: 25.1.8 + electron-builder-squirrel-windows: 25.1.8 + + aproba@2.1.0: + resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} + + archiver-utils@2.1.0: + resolution: {integrity: sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==} + engines: {node: '>= 6'} + + archiver-utils@3.0.4: + resolution: {integrity: sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==} + engines: {node: '>= 10'} + + archiver@5.3.2: + resolution: {integrity: sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==} + engines: {node: '>= 10'} + + are-we-there-yet@3.0.1: + resolution: {integrity: sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + assert-plus@1.0.0: + resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} + engines: {node: '>=0.8'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + async-exit-hook@2.0.1: + resolution: {integrity: sha512-NW2cX8m1Q7KPA7a5M2ULQeZ2wR5qI5PAbw5L0UOMxdioVk9PMZ0h1TmyZEkPYrCvYjDlFICusOu1dlEKAAeXBw==} + engines: {node: '>=0.12.0'} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + at-least-node@1.0.0: + resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==} + engines: {node: '>= 4.0.0'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + baseline-browser-mapping@2.10.38: + resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} + engines: {node: '>=6.0.0'} + hasBin: true + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bluebird-lst@1.0.9: + resolution: {integrity: sha512-7B1Rtx82hjnSD4PGLAjVWeYH3tHAcVUmChh85a3lltKQm6FresXh9ErQo6oAv6CqxttczC3/kEg8SY5NluPuUw==} + + bluebird@3.7.2: + resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} + + boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + brace-expansion@1.1.15: + resolution: {integrity: sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==} + + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + browserslist@4.28.4: + resolution: {integrity: sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + builder-util-runtime@9.2.10: + resolution: {integrity: sha512-6p/gfG1RJSQeIbz8TK5aPNkoztgY1q5TgmGFMAXcY8itsGW6Y2ld1ALsZ5UJn8rog7hKF3zHx5iQbNQ8uLcRlw==} + engines: {node: '>=12.0.0'} + + builder-util-runtime@9.7.0: + resolution: {integrity: sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw==} + engines: {node: '>=12.0.0'} + + builder-util@25.1.7: + resolution: {integrity: sha512-7jPjzBwEGRbwNcep0gGNpLXG9P94VA3CPAZQCzxkFXiV2GMQKlziMbY//rXPI7WKfhsvGgFXjTcXdBEwgXw9ww==} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + cacache@16.1.3: + resolution: {integrity: sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + cacheable-lookup@5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + + cacheable-request@7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + + cacheable@1.10.4: + resolution: {integrity: sha512-Gd7ccIUkZ9TE2odLQVS+PDjIvQCdJKUlLdJRVvZu0aipj07Qfx+XIej7hhDrKGGoIxV5m5fT/kOJNJPQhQneRg==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + + chromium-pickle-js@0.2.0: + resolution: {integrity: sha512-1R5Fho+jBq0DDydt+/vHWj5KJNJCKdARKOCwZUen84I5BreWoLqRLANH1U87eJy1tiASPtMnGqJJq0ZsLoRPOw==} + + ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + + clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-truncate@2.1.0: + resolution: {integrity: sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==} + engines: {node: '>=8'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clone-response@1.0.3: + resolution: {integrity: sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==} + + clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + + compare-version@0.1.2: + resolution: {integrity: sha512-pJDh5/4wrEnXX/VWRZvruAGHkzKdr46z11OlTPN+VrATlWWhSKewNCJ1futCO5C7eJB3nPMFZA1LeYtcFboZ2A==} + engines: {node: '>=0.10.0'} + + compress-commons@4.1.2: + resolution: {integrity: sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==} + engines: {node: '>= 10'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + config-file-ts@0.2.8-rc1: + resolution: {integrity: sha512-GtNECbVI82bT4RiDIzBSVuTKoSHufnU7Ce7/42bkWZJZFLjmDF2WBpVsvRkhKCfKBnTBb3qZrBwPpFBU/Myvhg==} + + console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + core-util-is@1.0.2: + resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@4.0.3: + resolution: {integrity: sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==} + engines: {node: '>= 10'} + + crc@3.8.0: + resolution: {integrity: sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + + defer-to-connect@2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + + dir-compare@4.2.0: + resolution: {integrity: sha512-2xMCmOoMrdQIPHdsTawECdNPwlVFB9zGcz3kuhmBO6U3oU+UQjsue0i8ayLKpgBcm+hcXPMVSGUN9d+pvJ6+VQ==} + + dmg-builder@25.1.8: + resolution: {integrity: sha512-NoXo6Liy2heSklTI5OIZbCgXC1RzrDQsZkeEwXhdOro3FT1VBOvbubvscdPnjVuQ4AMwwv61oaH96AbiYg9EnQ==} + + dmg-license@1.0.11: + resolution: {integrity: sha512-ZdzmqwKmECOWJpqefloC5OJy1+WZBBse5+MR88z9g9Zn4VY+WYUkAyojmhzJckH5YbbZGcYIuGAkY5/Ys5OM2Q==} + engines: {node: '>=8'} + os: [darwin] + hasBin: true + + dotenv-expand@11.0.7: + resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==} + engines: {node: '>=12'} + + dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + ejs@3.1.10: + resolution: {integrity: sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==} + engines: {node: '>=0.10.0'} + hasBin: true + + electron-builder-squirrel-windows@25.1.8: + resolution: {integrity: sha512-2ntkJ+9+0GFP6nAISiMabKt6eqBB0kX1QqHNWFWAXgi0VULKGisM46luRFpIBiU3u/TDmhZMM8tzvo2Abn3ayg==} + + electron-builder@25.1.8: + resolution: {integrity: sha512-poRgAtUHHOnlzZnc9PK4nzG53xh74wj2Jy7jkTrqZ0MWPoHGh1M2+C//hGeYdA+4K8w4yiVCNYoLXF7ySj2Wig==} + engines: {node: '>=14.0.0'} + hasBin: true + + electron-publish@25.1.7: + resolution: {integrity: sha512-+jbTkR9m39eDBMP4gfbqglDd6UvBC7RLh5Y0MhFSsc6UkGHj9Vj9TWobxevHYMMqmoujL11ZLjfPpMX+Pt6YEg==} + + electron-to-chromium@1.5.378: + resolution: {integrity: sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ==} + + electron-updater@6.8.9: + resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} + + electron-vite@2.3.0: + resolution: {integrity: sha512-lsN2FymgJlp4k6MrcsphGqZQ9fKRdJKasoaiwIrAewN1tapYI/KINLdfEL7n10LuF0pPSNf/IqjzZbB5VINctg==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@swc/core': ^1.0.0 + vite: ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + '@swc/core': + optional: true + + electron@33.4.11: + resolution: {integrity: sha512-xmdAs5QWRkInC7TpXGNvzo/7exojubk+72jn1oJL7keNeIlw7xNglf8TGtJtkR4rWC5FJq0oXiIXPS9BcK2Irg==} + engines: {node: '>= 12.20.55'} + hasBin: true + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + encoding@0.1.13: + resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + enhanced-resolve@5.21.6: + resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} + engines: {node: '>=10.13.0'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es6-error@4.1.1: + resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react-refresh@0.5.3: + resolution: {integrity: sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==} + peerDependencies: + eslint: ^9 || ^10 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.6.0: + resolution: {integrity: sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + exponential-backoff@3.1.3: + resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} + + extract-zip@2.0.1: + resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==} + engines: {node: '>= 10.17.0'} + hasBin: true + + extsprintf@1.4.1: + resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==} + engines: {'0': node >=0.6.0} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + filelist@1.0.6: + resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + fs-extra@11.3.5: + resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==} + engines: {node: '>=14.14'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@9.1.0: + resolution: {integrity: sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==} + engines: {node: '>=10'} + + fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + + fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gauge@4.0.4: + resolution: {integrity: sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@5.2.0: + resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + engines: {node: '>=8'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + global-agent@3.0.0: + resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} + engines: {node: '>=10.0'} + + globals@17.7.0: + resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + got@11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + + hosted-git-info@4.1.0: + resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==} + engines: {node: '>=10'} + + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + http2-wrapper@1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + + i18next@26.3.2: + resolution: {integrity: sha512-QQkXAM1sPDHqhxMQuBeHVMUn6mJchF+wdpOoQerciLAFqO3ZYdxO0EUbeEhruyutnNwpUQIITDVzLjwnNL0T1w==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + + iconv-corefoundation@1.1.7: + resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} + engines: {node: ^8.11.2 || >=10} + os: [darwin] + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + infer-owner@1.0.4: + resolution: {integrity: sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==} + + inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + is-ci@3.0.1: + resolution: {integrity: sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + is-lambda@1.0.1: + resolution: {integrity: sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==} + + is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + isbinaryfile@4.0.10: + resolution: {integrity: sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==} + engines: {node: '>= 8.0.0'} + + isbinaryfile@5.0.7: + resolution: {integrity: sha512-gnWD14Jh3FzS3CPhF0AxNOJ8CxqeblPTADzI38r0wt8ZyQl5edpy75myt08EG2oKvpyiqSqsx+Wkz9vtkbTqYQ==} + engines: {node: '>= 18.0.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jake@10.9.4: + resolution: {integrity: sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==} + engines: {node: '>=10'} + hasBin: true + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.2.0: + resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + jssha@3.3.1: + resolution: {integrity: sha512-VCMZj12FCFMQYcFLPRm/0lOBbLi8uM2BhXPTqw3U4YAfs4AZfiApOoBLoN8cQE60Z50m1MYMTQVCfgF/KaCVhQ==} + + keyv-file@5.3.4: + resolution: {integrity: sha512-WUuV2WhYoentWSPLOegXpub413WvvhWTziXWuoYVjpwSqK77cBDqnNs/RsmVxqznB4fjmHWbCEWYSf4Njat+lw==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + lazy-val@1.0.5: + resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + + lodash.difference@4.5.0: + resolution: {integrity: sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==} + + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.flatten@4.4.0: + resolution: {integrity: sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.union@4.6.0: + resolution: {integrity: sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==} + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + + lowercase-keys@2.0.0: + resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} + engines: {node: '>=8'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + + lru-cache@7.18.3: + resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} + engines: {node: '>=12'} + + lucide-react@1.21.0: + resolution: {integrity: sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + make-fetch-happen@10.2.1: + resolution: {integrity: sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + matcher@3.0.0: + resolution: {integrity: sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-response@1.0.1: + resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} + engines: {node: '>=4'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + minipass-collect@1.0.2: + resolution: {integrity: sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==} + engines: {node: '>= 8'} + + minipass-fetch@2.1.2: + resolution: {integrity: sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + minipass-flush@1.0.7: + resolution: {integrity: sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==} + engines: {node: '>= 8'} + + minipass-pipeline@1.2.4: + resolution: {integrity: sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==} + engines: {node: '>=8'} + + minipass-sized@1.0.3: + resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} + engines: {node: '>=8'} + + minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + + minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + + mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@0.6.4: + resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==} + engines: {node: '>= 0.6'} + + node-abi@3.92.0: + resolution: {integrity: sha512-KdHvFWZjEKDf0cakgFjebl371GPsISX2oZHcuyKqM7DtogIsHrqKeLTo8wBHxaXRAQlY2PsPlZmfo+9ZCxEREQ==} + engines: {node: '>=10'} + + node-addon-api@1.7.2: + resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==} + + node-api-version@0.2.1: + resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} + + node-gyp@9.4.1: + resolution: {integrity: sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==} + engines: {node: ^12.13 || ^14.13 || >=16} + hasBin: true + + node-releases@2.0.49: + resolution: {integrity: sha512-f06bl1D+8ZDkn2oOQQKAh5/otFWqVnM1Q5oerA8Pex7UfT66Tx4IPHIqVVFKqFT3FUtaDstdgkM7yT7JWhqxfw==} + engines: {node: '>=18'} + + nopt@6.0.0: + resolution: {integrity: sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + normalize-url@6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + + npmlog@6.0.2: + resolution: {integrity: sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + deprecated: This package is no longer supported. + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + + p-cancelable@2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pe-library@0.4.1: + resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==} + engines: {node: '>=12', npm: '>=6'} + + pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + playwright-core@1.61.1: + resolution: {integrity: sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==} + engines: {node: '>=18'} + hasBin: true + + plist@3.1.1: + resolution: {integrity: sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==} + engines: {node: '>=10.4.0'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier@3.8.4: + resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} + engines: {node: '>=14'} + hasBin: true + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + + promise-inflight@1.0.1: + resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==} + peerDependencies: + bluebird: '*' + peerDependenciesMeta: + bluebird: + optional: true + + promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + quick-lru@5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-i18next@17.0.8: + resolution: {integrity: sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + read-binary-file-arch@1.0.6: + resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==} + hasBin: true + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resedit@1.7.2: + resolution: {integrity: sha512-vHjcY2MlAITJhC0eRD/Vv8Vlgmu9Sd3LX9zZvtGzU5ZImdTN3+d6e/4mnTyV8vEbyf1sgNIrWxhWlrys52OkEA==} + engines: {node: '>=12', npm: '>=6'} + + resolve-alpn@1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + + responselike@2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + + restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + + rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + + roarr@2.15.4: + resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} + engines: {node: '>=8.0'} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sanitize-filename@1.6.4: + resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + serialize-error@7.0.1: + resolution: {integrity: sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw==} + engines: {node: '>=10'} + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + engines: {node: '>=20.9.0'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + + slice-ansi@3.0.0: + resolution: {integrity: sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==} + engines: {node: '>=8'} + + smart-buffer@4.2.0: + resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} + engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + + socks-proxy-agent@7.0.0: + resolution: {integrity: sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==} + engines: {node: '>= 10'} + + socks@2.8.9: + resolution: {integrity: sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==} + engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + + ssri@9.0.1: + resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + stat-mode@1.0.0: + resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==} + engines: {node: '>= 6'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + sumchecker@3.0.1: + resolution: {integrity: sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg==} + engines: {node: '>= 8.0'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tailwindcss@4.3.1: + resolution: {integrity: sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + + temp-file@3.4.0: + resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==} + + tiny-typed-emitter@2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tmp-promise@3.0.3: + resolution: {integrity: sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==} + + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + + totp-generator@2.0.1: + resolution: {integrity: sha512-50DiKmv9zKTPzCgWOqQYVBMvxh+tpL9O3IUFIqzGUlFXzJyb/IQZac8bonXudvLbfuDY8laZ9qTDX+yAvTBNSQ==} + + truncate-utf8-bytes@1.0.2: + resolution: {integrity: sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ==} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@0.13.1: + resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} + engines: {node: '>=10'} + + typescript-eslint@8.62.0: + resolution: {integrity: sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unique-filename@2.0.1: + resolution: {integrity: sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + unique-slug@3.0.0: + resolution: {integrity: sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + utf8-byte-length@1.0.5: + resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + verror@1.10.1: + resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} + engines: {node: '>=0.6.0'} + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + vrchat@2.21.7: + resolution: {integrity: sha512-ErfMhTnHR1uG4H7bUWxe2Vl6XnOdBxC7gqNL+yQufvFgIU0uTWzKf4D5sV8ibFlH8KKkB6AFUOmeKg7F6VnLMg==} + + wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xmlbuilder@15.1.1: + resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} + engines: {node: '>=8.0'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zip-stream@4.1.1: + resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} + engines: {node: '>= 10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + + zustand@5.0.14: + resolution: {integrity: sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + 7zip-bin@5.2.0: {} + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.4 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-arrow-functions@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@develar/schema-utils@2.6.5': + dependencies: + ajv: 6.15.0 + ajv-keywords: 3.5.2(ajv@6.15.0) + + '@electron/asar@3.4.1': + dependencies: + commander: 5.1.0 + glob: 7.2.3 + minimatch: 3.1.5 + + '@electron/get@2.0.3': + dependencies: + debug: 4.4.3 + env-paths: 2.2.1 + fs-extra: 8.1.0 + got: 11.8.6 + progress: 2.0.3 + semver: 6.3.1 + sumchecker: 3.0.1 + optionalDependencies: + global-agent: 3.0.0 + transitivePeerDependencies: + - supports-color + + '@electron/notarize@2.5.0': + dependencies: + debug: 4.4.3 + fs-extra: 9.1.0 + promise-retry: 2.0.1 + transitivePeerDependencies: + - supports-color + + '@electron/osx-sign@1.3.1': + dependencies: + compare-version: 0.1.2 + debug: 4.4.3 + fs-extra: 10.1.0 + isbinaryfile: 4.0.10 + minimist: 1.2.8 + plist: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@electron/rebuild@3.6.1': + dependencies: + '@malept/cross-spawn-promise': 2.0.0 + chalk: 4.1.2 + debug: 4.4.3 + detect-libc: 2.1.2 + fs-extra: 10.1.0 + got: 11.8.6 + node-abi: 3.92.0 + node-api-version: 0.2.1 + node-gyp: 9.4.1 + ora: 5.4.1 + read-binary-file-arch: 1.0.6 + semver: 7.8.5 + tar: 6.2.1 + yargs: 17.7.3 + transitivePeerDependencies: + - bluebird + - supports-color + + '@electron/universal@2.0.1': + dependencies: + '@electron/asar': 3.4.1 + '@malept/cross-spawn-promise': 2.0.0 + debug: 4.4.3 + dir-compare: 4.2.0 + fs-extra: 11.3.5 + minimatch: 9.0.9 + plist: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.6.0(jiti@2.7.0))': + dependencies: + eslint: 10.6.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.6.0(jiti@2.7.0))': + optionalDependencies: + eslint: 10.6.0(jiti@2.7.0) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@gar/promisify@1.1.3': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.1 + optional: true + + '@img/sharp-darwin-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.1 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.1': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.1': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + optional: true + + '@img/sharp-linux-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.1 + optional: true + + '@img/sharp-linux-arm@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.1 + optional: true + + '@img/sharp-linux-ppc64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.1 + optional: true + + '@img/sharp-linux-riscv64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.1 + optional: true + + '@img/sharp-linux-s390x@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.1 + optional: true + + '@img/sharp-linux-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + optional: true + + '@img/sharp-wasm32@0.35.2': + dependencies: + '@emnapi/runtime': 1.11.1 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-win32-arm64@0.35.2': + optional: true + + '@img/sharp-win32-ia32@0.35.2': + optional: true + + '@img/sharp-win32-x64@0.35.2': + optional: true + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@keyv/serialize@1.1.1': {} + + '@malept/cross-spawn-promise@2.0.0': + dependencies: + cross-spawn: 7.0.6 + + '@malept/flatpak-bundler@0.4.0': + dependencies: + debug: 4.4.3 + fs-extra: 9.1.0 + lodash: 4.18.1 + tmp-promise: 3.0.3 + transitivePeerDependencies: + - supports-color + + '@npmcli/fs@2.1.2': + dependencies: + '@gar/promisify': 1.1.3 + semver: 7.8.5 + + '@npmcli/move-file@2.0.1': + dependencies: + mkdirp: 1.0.4 + rimraf: 3.0.2 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@sindresorhus/is@4.6.0': {} + + '@szmarczak/http-timer@4.0.6': + dependencies: + defer-to-connect: 2.0.1 + + '@tailwindcss/node@4.3.1': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.6 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.1 + + '@tailwindcss/oxide-android-arm64@4.3.1': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.1': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.1': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.1': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.1': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.1': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.1': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.1': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.1': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.1': + optional: true + + '@tailwindcss/oxide@4.3.1': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.1 + '@tailwindcss/oxide-darwin-arm64': 4.3.1 + '@tailwindcss/oxide-darwin-x64': 4.3.1 + '@tailwindcss/oxide-freebsd-x64': 4.3.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.1 + '@tailwindcss/oxide-linux-x64-musl': 4.3.1 + '@tailwindcss/oxide-wasm32-wasi': 4.3.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.1 + + '@tailwindcss/vite@4.3.1(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@tailwindcss/node': 4.3.1 + '@tailwindcss/oxide': 4.3.1 + tailwindcss: 4.3.1 + vite: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0) + + '@tootallnate/once@2.0.1': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/cacheable-request@6.0.3': + dependencies: + '@types/http-cache-semantics': 4.2.0 + '@types/keyv': 3.1.4 + '@types/node': 22.20.0 + '@types/responselike': 1.0.3 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/fs-extra@9.0.13': + dependencies: + '@types/node': 22.20.0 + + '@types/http-cache-semantics@4.2.0': {} + + '@types/json-schema@7.0.15': {} + + '@types/keyv@3.1.4': + dependencies: + '@types/node': 22.20.0 + + '@types/ms@2.1.0': {} + + '@types/node@20.19.43': + dependencies: + undici-types: 6.21.0 + + '@types/node@22.20.0': + dependencies: + undici-types: 6.21.0 + + '@types/plist@3.0.5': + dependencies: + '@types/node': 22.20.0 + xmlbuilder: 15.1.1 + optional: true + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@types/responselike@1.0.3': + dependencies: + '@types/node': 22.20.0 + + '@types/verror@1.10.11': + optional: true + + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.20.0 + + '@types/yauzl@2.10.3': + dependencies: + '@types/node': 22.20.0 + optional: true + + '@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.6.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.62.0 + '@typescript-eslint/type-utils': 8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.62.0 + eslint: 10.6.0(jiti@2.7.0) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.62.0 + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.62.0 + debug: 4.4.3 + eslint: 10.6.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.62.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@5.9.3) + '@typescript-eslint/types': 8.62.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.62.0': + dependencies: + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/visitor-keys': 8.62.0 + + '@typescript-eslint/tsconfig-utils@8.62.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.6.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.62.0': {} + + '@typescript-eslint/typescript-estree@8.62.0(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.62.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.62.0(typescript@5.9.3) + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/visitor-keys': 8.62.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.62.0 + '@typescript-eslint/types': 8.62.0 + '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.9.3) + eslint: 10.6.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.62.0': + dependencies: + '@typescript-eslint/types': 8.62.0 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0) + transitivePeerDependencies: + - supports-color + + '@xmldom/xmldom@0.9.10': {} + + abbrev@1.1.1: {} + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + agent-base@7.1.4: {} + + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + + aggregate-error@3.1.0: + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + ajv-keywords@3.5.2(ajv@6.15.0): + dependencies: + ajv: 6.15.0 + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + app-builder-bin@5.0.0-alpha.10: {} + + app-builder-lib@25.1.8(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8): + dependencies: + '@develar/schema-utils': 2.6.5 + '@electron/notarize': 2.5.0 + '@electron/osx-sign': 1.3.1 + '@electron/rebuild': 3.6.1 + '@electron/universal': 2.0.1 + '@malept/flatpak-bundler': 0.4.0 + '@types/fs-extra': 9.0.13 + async-exit-hook: 2.0.1 + bluebird-lst: 1.0.9 + builder-util: 25.1.7 + builder-util-runtime: 9.2.10 + chromium-pickle-js: 0.2.0 + config-file-ts: 0.2.8-rc1 + debug: 4.4.3 + dmg-builder: 25.1.8(electron-builder-squirrel-windows@25.1.8) + dotenv: 16.6.1 + dotenv-expand: 11.0.7 + ejs: 3.1.10 + electron-builder-squirrel-windows: 25.1.8(dmg-builder@25.1.8) + electron-publish: 25.1.7 + form-data: 4.0.6 + fs-extra: 10.1.0 + hosted-git-info: 4.1.0 + is-ci: 3.0.1 + isbinaryfile: 5.0.7 + js-yaml: 4.2.0 + json5: 2.2.3 + lazy-val: 1.0.5 + minimatch: 10.2.5 + resedit: 1.7.2 + sanitize-filename: 1.6.4 + semver: 7.8.5 + tar: 6.2.1 + temp-file: 3.4.0 + transitivePeerDependencies: + - bluebird + - supports-color + + aproba@2.1.0: {} + + archiver-utils@2.1.0: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 2.3.8 + + archiver-utils@3.0.4: + dependencies: + glob: 7.2.3 + graceful-fs: 4.2.11 + lazystream: 1.0.1 + lodash.defaults: 4.2.0 + lodash.difference: 4.5.0 + lodash.flatten: 4.4.0 + lodash.isplainobject: 4.0.6 + lodash.union: 4.6.0 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + archiver@5.3.2: + dependencies: + archiver-utils: 2.1.0 + async: 3.2.6 + buffer-crc32: 0.2.13 + readable-stream: 3.6.2 + readdir-glob: 1.1.3 + tar-stream: 2.2.0 + zip-stream: 4.1.1 + + are-we-there-yet@3.0.1: + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + + argparse@2.0.1: {} + + assert-plus@1.0.0: + optional: true + + astral-regex@2.0.0: + optional: true + + async-exit-hook@2.0.1: {} + + async@3.2.6: {} + + asynckit@0.4.0: {} + + at-least-node@1.0.0: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + base64-js@1.5.1: {} + + baseline-browser-mapping@2.10.38: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bluebird-lst@1.0.9: + dependencies: + bluebird: 3.7.2 + + bluebird@3.7.2: {} + + boolean@3.2.0: + optional: true + + brace-expansion@1.1.15: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.4: + dependencies: + baseline-browser-mapping: 2.10.38 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.378 + node-releases: 2.0.49 + update-browserslist-db: 1.2.3(browserslist@4.28.4) + + buffer-crc32@0.2.13: {} + + buffer-from@1.1.2: {} + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + builder-util-runtime@9.2.10: + dependencies: + debug: 4.4.3 + sax: 1.6.0 + transitivePeerDependencies: + - supports-color + + builder-util-runtime@9.7.0: + dependencies: + debug: 4.4.3 + sax: 1.6.0 + transitivePeerDependencies: + - supports-color + + builder-util@25.1.7: + dependencies: + 7zip-bin: 5.2.0 + '@types/debug': 4.1.13 + app-builder-bin: 5.0.0-alpha.10 + bluebird-lst: 1.0.9 + builder-util-runtime: 9.2.10 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + fs-extra: 10.1.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-ci: 3.0.1 + js-yaml: 4.2.0 + source-map-support: 0.5.21 + stat-mode: 1.0.0 + temp-file: 3.4.0 + transitivePeerDependencies: + - supports-color + + cac@6.7.14: {} + + cacache@16.1.3: + dependencies: + '@npmcli/fs': 2.1.2 + '@npmcli/move-file': 2.0.1 + chownr: 2.0.0 + fs-minipass: 2.1.0 + glob: 8.1.0 + infer-owner: 1.0.4 + lru-cache: 7.18.3 + minipass: 3.3.6 + minipass-collect: 1.0.2 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + mkdirp: 1.0.4 + p-map: 4.0.0 + promise-inflight: 1.0.1 + rimraf: 3.0.2 + ssri: 9.0.1 + tar: 6.2.1 + unique-filename: 2.0.1 + transitivePeerDependencies: + - bluebird + + cacheable-lookup@5.0.4: {} + + cacheable-request@7.0.4: + dependencies: + clone-response: 1.0.3 + get-stream: 5.2.0 + http-cache-semantics: 4.2.0 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + + cacheable@1.10.4: + dependencies: + hookified: 1.15.1 + keyv: 5.6.0 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + caniuse-lite@1.0.30001799: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chownr@2.0.0: {} + + chromium-pickle-js@0.2.0: {} + + ci-info@3.9.0: {} + + clean-stack@2.2.0: {} + + cli-cursor@3.1.0: + dependencies: + restore-cursor: 3.1.0 + + cli-spinners@2.9.2: {} + + cli-truncate@2.1.0: + dependencies: + slice-ansi: 3.0.0 + string-width: 4.2.3 + optional: true + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clone-response@1.0.3: + dependencies: + mimic-response: 1.0.1 + + clone@1.0.4: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + color-support@1.1.3: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@5.1.0: {} + + compare-version@0.1.2: {} + + compress-commons@4.1.2: + dependencies: + buffer-crc32: 0.2.13 + crc32-stream: 4.0.3 + normalize-path: 3.0.0 + readable-stream: 3.6.2 + + concat-map@0.0.1: {} + + config-file-ts@0.2.8-rc1: + dependencies: + glob: 10.5.0 + typescript: 5.9.3 + + console-control-strings@1.1.0: {} + + convert-source-map@2.0.0: {} + + core-util-is@1.0.2: + optional: true + + core-util-is@1.0.3: {} + + crc-32@1.2.2: {} + + crc32-stream@4.0.3: + dependencies: + crc-32: 1.2.2 + readable-stream: 3.6.2 + + crc@3.8.0: + dependencies: + buffer: 5.7.1 + optional: true + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-is@0.1.4: {} + + defaults@1.0.4: + dependencies: + clone: 1.0.4 + + defer-to-connect@2.0.1: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + optional: true + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + optional: true + + delayed-stream@1.0.0: {} + + delegates@1.0.0: {} + + detect-libc@2.1.2: {} + + detect-node@2.1.0: + optional: true + + dir-compare@4.2.0: + dependencies: + minimatch: 3.1.5 + p-limit: 3.1.0 + + dmg-builder@25.1.8(electron-builder-squirrel-windows@25.1.8): + dependencies: + app-builder-lib: 25.1.8(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8) + builder-util: 25.1.7 + builder-util-runtime: 9.2.10 + fs-extra: 10.1.0 + iconv-lite: 0.6.3 + js-yaml: 4.2.0 + optionalDependencies: + dmg-license: 1.0.11 + transitivePeerDependencies: + - bluebird + - electron-builder-squirrel-windows + - supports-color + + dmg-license@1.0.11: + dependencies: + '@types/plist': 3.0.5 + '@types/verror': 1.10.11 + ajv: 6.15.0 + crc: 3.8.0 + iconv-corefoundation: 1.1.7 + plist: 3.1.1 + smart-buffer: 4.2.0 + verror: 1.10.1 + optional: true + + dotenv-expand@11.0.7: + dependencies: + dotenv: 16.6.1 + + dotenv@16.6.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eastasianwidth@0.2.0: {} + + ejs@3.1.10: + dependencies: + jake: 10.9.4 + + electron-builder-squirrel-windows@25.1.8(dmg-builder@25.1.8): + dependencies: + app-builder-lib: 25.1.8(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8) + archiver: 5.3.2 + builder-util: 25.1.7 + fs-extra: 10.1.0 + transitivePeerDependencies: + - bluebird + - dmg-builder + - supports-color + + electron-builder@25.1.8(electron-builder-squirrel-windows@25.1.8): + dependencies: + app-builder-lib: 25.1.8(dmg-builder@25.1.8)(electron-builder-squirrel-windows@25.1.8) + builder-util: 25.1.7 + builder-util-runtime: 9.2.10 + chalk: 4.1.2 + dmg-builder: 25.1.8(electron-builder-squirrel-windows@25.1.8) + fs-extra: 10.1.0 + is-ci: 3.0.1 + lazy-val: 1.0.5 + simple-update-notifier: 2.0.0 + yargs: 17.7.3 + transitivePeerDependencies: + - bluebird + - electron-builder-squirrel-windows + - supports-color + + electron-publish@25.1.7: + dependencies: + '@types/fs-extra': 9.0.13 + builder-util: 25.1.7 + builder-util-runtime: 9.2.10 + chalk: 4.1.2 + fs-extra: 10.1.0 + lazy-val: 1.0.5 + mime: 2.6.0 + transitivePeerDependencies: + - supports-color + + electron-to-chromium@1.5.378: {} + + electron-updater@6.8.9: + dependencies: + builder-util-runtime: 9.7.0 + fs-extra: 10.1.0 + js-yaml: 4.2.0 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.4 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + + electron-vite@2.3.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)): + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7) + cac: 6.7.14 + esbuild: 0.21.5 + magic-string: 0.30.21 + picocolors: 1.1.1 + vite: 6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0) + transitivePeerDependencies: + - supports-color + + electron@33.4.11: + dependencies: + '@electron/get': 2.0.3 + '@types/node': 20.19.43 + extract-zip: 2.0.1 + transitivePeerDependencies: + - supports-color + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + encoding@0.1.13: + dependencies: + iconv-lite: 0.6.3 + optional: true + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + enhanced-resolve@5.21.6: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + env-paths@2.2.1: {} + + err-code@2.0.3: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + es6-error@4.1.1: + optional: true + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.1.1(eslint@10.6.0(jiti@2.7.0)): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + eslint: 10.6.0(jiti@2.7.0) + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.5.3(eslint@10.6.0(jiti@2.7.0)): + dependencies: + eslint: 10.6.0(jiti@2.7.0) + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.6.0(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.6.0(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + exponential-backoff@3.1.3: {} + + extract-zip@2.0.1: + dependencies: + debug: 4.4.3 + get-stream: 5.2.0 + yauzl: 2.10.0 + optionalDependencies: + '@types/yauzl': 2.10.3 + transitivePeerDependencies: + - supports-color + + extsprintf@1.4.1: + optional: true + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fd-slicer@1.1.0: + dependencies: + pend: 1.2.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + filelist@1.0.6: + dependencies: + minimatch: 5.1.9 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fs-constants@1.0.0: {} + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@11.3.5: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@9.1.0: + dependencies: + at-least-node: 1.0.0 + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fs-minipass@2.1.0: + dependencies: + minipass: 3.3.6 + + fs.realpath@1.0.0: {} + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gauge@4.0.4: + dependencies: + aproba: 2.1.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@5.2.0: + dependencies: + pump: 3.0.4 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@7.2.3: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.5 + once: 1.4.0 + path-is-absolute: 1.0.1 + + glob@8.1.0: + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.9 + once: 1.4.0 + + global-agent@3.0.0: + dependencies: + boolean: 3.2.0 + es6-error: 4.1.1 + matcher: 3.0.0 + roarr: 2.15.4 + semver: 7.8.5 + serialize-error: 7.0.1 + optional: true + + globals@17.7.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + optional: true + + gopd@1.2.0: {} + + got@11.8.6: + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.3 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + optional: true + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + has-unicode@2.0.1: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + hookified@1.15.1: {} + + hosted-git-info@4.1.0: + dependencies: + lru-cache: 6.0.0 + + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + http-cache-semantics@4.2.0: {} + + http-proxy-agent@5.0.0: + dependencies: + '@tootallnate/once': 2.0.1 + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + http2-wrapper@1.0.3: + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + + i18next@26.3.2(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + iconv-corefoundation@1.1.7: + dependencies: + cli-truncate: 2.1.0 + node-addon-api: 1.7.2 + optional: true + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + ieee754@1.2.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + imurmurhash@0.1.4: {} + + indent-string@4.0.0: {} + + infer-owner@1.0.4: {} + + inflight@1.0.6: + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + inherits@2.0.4: {} + + ip-address@10.2.0: {} + + is-ci@3.0.1: + dependencies: + ci-info: 3.9.0 + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-interactive@1.0.0: {} + + is-lambda@1.0.1: {} + + is-unicode-supported@0.1.0: {} + + isarray@1.0.0: {} + + isbinaryfile@4.0.10: {} + + isbinaryfile@5.0.7: {} + + isexe@2.0.0: {} + + isomorphic-ws@5.0.0(ws@8.21.0): + dependencies: + ws: 8.21.0 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jake@10.9.4: + dependencies: + async: 3.2.6 + filelist: 1.0.6 + picocolors: 1.1.1 + + jiti@2.7.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.2.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json-stringify-safe@5.0.1: + optional: true + + json5@2.2.3: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + jssha@3.3.1: {} + + keyv-file@5.3.4: + dependencies: + '@keyv/serialize': 1.1.1 + tslib: 1.14.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + lazy-val@1.0.5: {} + + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.defaults@4.2.0: {} + + lodash.difference@4.5.0: {} + + lodash.escaperegexp@4.1.2: {} + + lodash.flatten@4.4.0: {} + + lodash.isequal@4.5.0: {} + + lodash.isplainobject@4.0.6: {} + + lodash.union@4.6.0: {} + + lodash@4.18.1: {} + + log-symbols@4.1.0: + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + lowercase-keys@2.0.0: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + + lru-cache@7.18.3: {} + + lucide-react@1.21.0(react@19.2.7): + dependencies: + react: 19.2.7 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + make-fetch-happen@10.2.1: + dependencies: + agentkeepalive: 4.6.0 + cacache: 16.1.3 + http-cache-semantics: 4.2.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-lambda: 1.0.1 + lru-cache: 7.18.3 + minipass: 3.3.6 + minipass-collect: 1.0.2 + minipass-fetch: 2.1.2 + minipass-flush: 1.0.7 + minipass-pipeline: 1.2.4 + negotiator: 0.6.4 + promise-retry: 2.0.1 + socks-proxy-agent: 7.0.0 + ssri: 9.0.1 + transitivePeerDependencies: + - bluebird + - supports-color + + matcher@3.0.0: + dependencies: + escape-string-regexp: 4.0.0 + optional: true + + math-intrinsics@1.1.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@2.6.0: {} + + mimic-fn@2.1.0: {} + + mimic-response@1.0.1: {} + + mimic-response@3.1.0: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.15 + + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.1 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.1 + + minimist@1.2.8: {} + + minipass-collect@1.0.2: + dependencies: + minipass: 3.3.6 + + minipass-fetch@2.1.2: + dependencies: + minipass: 3.3.6 + minipass-sized: 1.0.3 + minizlib: 2.1.2 + optionalDependencies: + encoding: 0.1.13 + + minipass-flush@1.0.7: + dependencies: + minipass: 3.3.6 + + minipass-pipeline@1.2.4: + dependencies: + minipass: 3.3.6 + + minipass-sized@1.0.3: + dependencies: + minipass: 3.3.6 + + minipass@3.3.6: + dependencies: + yallist: 4.0.0 + + minipass@5.0.0: {} + + minipass@7.1.3: {} + + minizlib@2.1.2: + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + + mkdirp@1.0.4: {} + + ms@2.1.3: {} + + nanoid@3.3.15: {} + + natural-compare@1.4.0: {} + + negotiator@0.6.4: {} + + node-abi@3.92.0: + dependencies: + semver: 7.8.5 + + node-addon-api@1.7.2: + optional: true + + node-api-version@0.2.1: + dependencies: + semver: 7.8.5 + + node-gyp@9.4.1: + dependencies: + env-paths: 2.2.1 + exponential-backoff: 3.1.3 + glob: 7.2.3 + graceful-fs: 4.2.11 + make-fetch-happen: 10.2.1 + nopt: 6.0.0 + npmlog: 6.0.2 + rimraf: 3.0.2 + semver: 7.8.5 + tar: 6.2.1 + which: 2.0.2 + transitivePeerDependencies: + - bluebird + - supports-color + + node-releases@2.0.49: {} + + nopt@6.0.0: + dependencies: + abbrev: 1.1.1 + + normalize-path@3.0.0: {} + + normalize-url@6.1.0: {} + + npmlog@6.0.2: + dependencies: + are-we-there-yet: 3.0.1 + console-control-strings: 1.1.0 + gauge: 4.0.4 + set-blocking: 2.0.0 + + object-keys@1.1.1: + optional: true + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@5.4.1: + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + p-cancelable@2.1.1: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + p-map@4.0.0: + dependencies: + aggregate-error: 3.1.0 + + package-json-from-dist@1.0.1: {} + + path-exists@4.0.0: {} + + path-is-absolute@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + pe-library@0.4.1: {} + + pend@1.2.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + playwright-core@1.61.1: {} + + plist@3.1.1: + dependencies: + '@xmldom/xmldom': 0.9.10 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.8.4: {} + + process-nextick-args@2.0.1: {} + + progress@2.0.3: {} + + promise-inflight@1.0.1: {} + + promise-retry@2.0.1: + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + punycode@2.3.1: {} + + quick-lru@5.1.1: {} + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-i18next@17.0.8(i18next@26.3.2(typescript@5.9.3))(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 3.0.1 + i18next: 26.3.2(typescript@5.9.3) + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + typescript: 5.9.3 + + react-refresh@0.17.0: {} + + react@19.2.7: {} + + read-binary-file-arch@1.0.6: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + + require-directory@2.1.1: {} + + resedit@1.7.2: + dependencies: + pe-library: 0.4.1 + + resolve-alpn@1.2.1: {} + + responselike@2.0.1: + dependencies: + lowercase-keys: 2.0.0 + + restore-cursor@3.1.0: + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + retry@0.12.0: {} + + rimraf@3.0.2: + dependencies: + glob: 7.2.3 + + roarr@2.15.4: + dependencies: + boolean: 3.2.0 + detect-node: 2.1.0 + globalthis: 1.0.4 + json-stringify-safe: 5.0.1 + semver-compare: 1.0.0 + sprintf-js: 1.1.3 + optional: true + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + safe-buffer@5.1.2: {} + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + sanitize-filename@1.6.4: + dependencies: + truncate-utf8-bytes: 1.0.2 + + sax@1.6.0: {} + + scheduler@0.27.0: {} + + semver-compare@1.0.0: + optional: true + + semver@6.3.1: {} + + semver@7.7.4: {} + + semver@7.8.5: {} + + serialize-error@7.0.1: + dependencies: + type-fest: 0.13.1 + optional: true + + set-blocking@2.0.0: {} + + sharp@0.35.2: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + simple-update-notifier@2.0.0: + dependencies: + semver: 7.8.5 + + slice-ansi@3.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + optional: true + + smart-buffer@4.2.0: {} + + socks-proxy-agent@7.0.0: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + socks: 2.8.9 + transitivePeerDependencies: + - supports-color + + socks@2.8.9: + dependencies: + ip-address: 10.2.0 + smart-buffer: 4.2.0 + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + sprintf-js@1.1.3: + optional: true + + ssri@9.0.1: + dependencies: + minipass: 3.3.6 + + stat-mode@1.0.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + sumchecker@3.0.1: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tailwindcss@4.3.1: {} + + tapable@2.3.3: {} + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar@6.2.1: + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + + temp-file@3.4.0: + dependencies: + async-exit-hook: 2.0.1 + fs-extra: 10.1.0 + + tiny-typed-emitter@2.1.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tmp-promise@3.0.3: + dependencies: + tmp: 0.2.7 + + tmp@0.2.7: {} + + totp-generator@2.0.1: + dependencies: + jssha: 3.3.1 + + truncate-utf8-bytes@1.0.2: + dependencies: + utf8-byte-length: 1.0.5 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tslib@1.14.1: {} + + tslib@2.8.1: + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@0.13.1: + optional: true + + typescript-eslint@8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.62.0(@typescript-eslint/parser@8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.6.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.62.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.62.0(eslint@10.6.0(jiti@2.7.0))(typescript@5.9.3) + eslint: 10.6.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + unique-filename@2.0.1: + dependencies: + unique-slug: 3.0.0 + + unique-slug@3.0.0: + dependencies: + imurmurhash: 0.1.4 + + universalify@0.1.2: {} + + universalify@2.0.1: {} + + update-browserslist-db@1.2.3(browserslist@4.28.4): + dependencies: + browserslist: 4.28.4 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + + utf8-byte-length@1.0.5: {} + + util-deprecate@1.0.2: {} + + verror@1.10.1: + dependencies: + assert-plus: 1.0.0 + core-util-is: 1.0.2 + extsprintf: 1.4.1 + optional: true + + vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.0 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + + void-elements@3.1.0: {} + + vrchat@2.21.7: + dependencies: + cacheable: 1.10.4 + debug: 4.4.3 + isomorphic-ws: 5.0.0(ws@8.21.0) + keyv: 5.6.0 + totp-generator: 2.0.1 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + wcwidth@1.0.1: + dependencies: + defaults: 1.0.4 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wide-align@1.1.5: + dependencies: + string-width: 4.2.3 + + word-wrap@1.2.5: {} + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + wrappy@1.0.2: {} + + ws@8.21.0: {} + + xmlbuilder@15.1.1: {} + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yallist@4.0.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yauzl@2.10.0: + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + + yocto-queue@0.1.0: {} + + zip-stream@4.1.1: + dependencies: + archiver-utils: 3.0.4 + compress-commons: 4.1.2 + readable-stream: 3.6.2 + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} + + zustand@5.0.14(@types/react@19.2.17)(react@19.2.7)(use-sync-external-store@1.6.0(react@19.2.7)): + optionalDependencies: + '@types/react': 19.2.17 + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..1edd8f8 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +onlyBuiltDependencies: + - electron + - esbuild + - unrs-resolver diff --git a/src/main/accounts/store.ts b/src/main/accounts/store.ts new file mode 100644 index 0000000..94ba221 --- /dev/null +++ b/src/main/accounts/store.ts @@ -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 }); +} diff --git a/src/main/cache/cache.ts b/src/main/cache/cache.ts new file mode 100644 index 0000000..d1044cd --- /dev/null +++ b/src/main/cache/cache.ts @@ -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 { + 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 }; + headers?: Record; + }; + 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>(); + private readonly inflight = new Map>(); + private readonly listeners = new Set(); + 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>>; + 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(key: string, policy: CachePolicy, loader: () => Promise): Promise { + const now = Date.now(); + const hit = this.store.get(key) as Entry | 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(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(key: string, partial: Partial): void { + const hit = this.store.get(key) as Entry | 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(key: string, policy: CachePolicy, loader: () => Promise): Promise { + const existing = this.inflight.get(key) as Promise | 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( + key: string, + policy: CachePolicy, + loader: () => Promise, + ): Promise { + 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 {} + } +} diff --git a/src/main/cache/policies.ts b/src/main/cache/policies.ts new file mode 100644 index 0000000..266c95c --- /dev/null +++ b/src/main/cache/policies.ts @@ -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; + +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}`, +}; diff --git a/src/main/config/appConfig.ts b/src/main/config/appConfig.ts new file mode 100644 index 0000000..940212a --- /dev/null +++ b/src/main/config/appConfig.ts @@ -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; +const DEFAULTS: StoredConfig = { gamePath: null }; + +function readStored(): StoredConfig { + try { + return { ...DEFAULTS, ...(JSON.parse(readFileSync(path(), "utf8")) as Partial) }; + } 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 }; +} diff --git a/src/main/debug/bridge.ts b/src/main/debug/bridge.ts new file mode 100644 index 0000000..dc7165c --- /dev/null +++ b/src/main/debug/bridge.ts @@ -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?.(); + }); +} diff --git a/src/main/debug/logger.ts b/src/main/debug/logger.ts new file mode 100644 index 0000000..c3c2016 --- /dev/null +++ b/src/main/debug/logger.ts @@ -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); +} diff --git a/src/main/debug/service.ts b/src/main/debug/service.ts new file mode 100644 index 0000000..e970710 --- /dev/null +++ b/src/main/debug/service.ts @@ -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(); +} diff --git a/src/main/debug/wsLog.ts b/src/main/debug/wsLog.ts new file mode 100644 index 0000000..b0dadda --- /dev/null +++ b/src/main/debug/wsLog.ts @@ -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); +} diff --git a/src/main/enhancements/screenshotSymlink.ts b/src/main/enhancements/screenshotSymlink.ts new file mode 100644 index 0000000..fb5061c --- /dev/null +++ b/src/main/enhancements/screenshotSymlink.ts @@ -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(); +} diff --git a/src/main/enhancements/service.ts b/src/main/enhancements/service.ts new file mode 100644 index 0000000..a3a623f --- /dev/null +++ b/src/main/enhancements/service.ts @@ -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>; + +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); + } +} diff --git a/src/main/gallery/metadata.ts b/src/main/gallery/metadata.ts new file mode 100644 index 0000000..c3e1761 --- /dev/null +++ b/src/main/gallery/metadata.ts @@ -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]*?)`)); + 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 { + 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("(); + 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; +} diff --git a/src/main/gallery/protocol.ts b/src/main/gallery/protocol.ts new file mode 100644 index 0000000..ea9e423 --- /dev/null +++ b/src/main/gallery/protocol.ts @@ -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()); + }); +} diff --git a/src/main/gallery/service.ts b/src/main/gallery/service.ts new file mode 100644 index 0000000..988797a --- /dev/null +++ b/src/main/gallery/service.ts @@ -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(); + +async function walk(dir: string, out: string[]): Promise { + 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 { + 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 { + const root = galleryRoots().find((r) => path === r || path.startsWith(r + sep)) ?? dirname(path); + return toPhoto(root, path); +} + +export async function snapshot(): Promise { + 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 { + 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).`); +} diff --git a/src/main/gallery/thumbnails.ts b/src/main/gallery/thumbnails.ts new file mode 100644 index 0000000..c1abbc8 --- /dev/null +++ b/src/main/gallery/thumbnails.ts @@ -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>(); + +export async function getThumbnail(srcPath: string): Promise { + let st: Awaited>; + 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 { + 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 { + 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 { + 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; + } +} diff --git a/src/main/gallery/watcher.ts b/src/main/gallery/watcher.ts new file mode 100644 index 0000000..30af395 --- /dev/null +++ b/src/main/gallery/watcher.ts @@ -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(); +const pending = new Set(); +const emitted = new Set(); + +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +// VRChat writes screenshots in chunks +async function settle(path: string): Promise { + 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 { + 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 { + 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(); +} diff --git a/src/main/game/launch.ts b/src/main/game/launch.ts new file mode 100644 index 0000000..2a913b2 --- /dev/null +++ b/src/main/game/launch.ts @@ -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 { + 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 { + if (!SUPPORTED) return { running: false, supported: false }; + return setRunning(await isRunning()); +} + +async function focus(): Promise { + if (process.platform !== "linux") return; + try { + await sh(`xdotool search --class steam_app_${VRCHAT_APPID} windowactivate %@`); + } catch {} +} + +export async function launch(): Promise { + 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); +} diff --git a/src/main/game/steam.ts b/src/main/game/steam.ts new file mode 100644 index 0000000..d503a9a --- /dev/null +++ b/src/main/game/steam.ts @@ -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(); + + 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; +} diff --git a/src/main/index.ts b/src/main/index.ts new file mode 100644 index 0000000..290501d --- /dev/null +++ b/src/main/index.ts @@ -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(); + }); +} diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts new file mode 100644 index 0000000..7c947db --- /dev/null +++ b/src/main/ipc/handlers.ts @@ -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) => Promise>; +}; + +export function registerIpcHandlers(): void { + for (const channel of Object.keys(handlers) as IpcRequestChannel[]) { + register(channel); + } +} + +function register(channel: C): void { + const handler = handlers[channel] as ( + ...args: Parameters + ) => Promise>; + ipcMain.handle(channel, (_event, ...args) => + handler(...(args as Parameters)), + ); +} diff --git a/src/main/lib/atomicFile.ts b/src/main/lib/atomicFile.ts new file mode 100644 index 0000000..d4c6b4d --- /dev/null +++ b/src/main/lib/atomicFile.ts @@ -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 { + 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); +} diff --git a/src/main/store/entityStore.ts b/src/main/store/entityStore.ts new file mode 100644 index 0000000..4f904f1 --- /dev/null +++ b/src/main/store/entityStore.ts @@ -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(); + 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 & { id: string }, at?: number): void { + this.upsertFrom(partial, "ws", at); + } + + upsertFrom(partial: Partial & { 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(); diff --git a/src/main/store/repository/backend.ts b/src/main/store/repository/backend.ts new file mode 100644 index 0000000..fd45a28 --- /dev/null +++ b/src/main/store/repository/backend.ts @@ -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 { + load(): Map>; + put(id: string, entity: StoredEntity): void; + remove(id: string): void; + flush(map: Map>): void; + clear(): void; + file: string | null; +} + +type LogLine = { op: "put"; id: string; e: StoredEntity } | { op: "del"; id: string }; + +export class JsonlBackend implements StorageBackend { + 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> { + const map = new Map>(); + if (existsSync(this.base)) { + try { + const raw = JSON.parse(readFileSync(this.base, "utf8")) as Record>; + 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; + 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): void { + this.appendLine({ op: "put", id, e: entity }); + } + + remove(id: string): void { + this.appendLine({ op: "del", id }); + } + + private appendLine(entry: LogLine): 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>): 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>): void { + try { + writeFileAtomicSync(this.base, JSON.stringify(Object.fromEntries(map))); + rmSync(this.log, { force: true }); + this.appendCount = 0; + } catch { + return; + } + } + + destroy(): void { + this.clear(); + } +} diff --git a/src/main/store/repository/fieldPolicy.ts b/src/main/store/repository/fieldPolicy.ts new file mode 100644 index 0000000..6757757 --- /dev/null +++ b/src/main/store/repository/fieldPolicy.ts @@ -0,0 +1,84 @@ +import type { FieldSource } from "../../../shared/types/repository"; + +export type FieldClass = "identity" | "stat" | "live"; + +export interface FieldPolicy { + classOf: (field: keyof T & string) => FieldClass; + maxAge: Record; + keepNonEmpty?: ReadonlySet; +} + +const SOURCE_PRIORITY: Record = { + 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( + classes: Partial>, + maxAge: Record, + keepNonEmpty?: ReadonlySet, +): FieldPolicy { + return { + classOf: (f) => classes[f] ?? "identity", + maxAge, + keepNonEmpty, + }; +} + +export const worldFieldPolicy = table( + { + 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( + { + 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( + { + favorites: "stat", + }, + { identity: 30 * DAY, stat: 6 * HOUR, live: 1 * MIN }, +); diff --git a/src/main/store/repository/manager.ts b/src/main/store/repository/manager.ts new file mode 100644 index 0000000..860ad68 --- /dev/null +++ b/src/main/store/repository/manager.ts @@ -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; + users: Repository; + avatars: Repository; +} + +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(); + private activeId: string | null = null; + + private open(accountId: string): AccountRepos { + let repos = this.accounts.get(accountId); + if (repos) return repos; + repos = { + worlds: new Repository({ + name: "worlds", + policy: worldFieldPolicy, + backend: new JsonlBackend(fileFor(accountId, "worlds")), + }), + users: new Repository({ + name: "users", + policy: userFieldPolicy, + backend: new JsonlBackend(fileFor(accountId, "users")), + staleLiveOnLoad: true, + }), + avatars: new Repository({ + name: "avatars", + policy: avatarFieldPolicy, + backend: new JsonlBackend(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(); diff --git a/src/main/store/repository/repository.ts b/src/main/store/repository/repository.ts new file mode 100644 index 0000000..1a729a2 --- /dev/null +++ b/src/main/store/repository/repository.ts @@ -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 = { type: "upsert"; id: string; entity: T }; +type Listener = (change: RepoChange) => void; + +const EVICT_INTERVAL_MS = 60_000; + +interface Entity { + id: string; +} + +export interface RepositoryOptions { + name: string; + policy: FieldPolicy; + backend: StorageBackend; + maxEntries?: number; + maxUnreadMs?: number; + staleLiveOnLoad?: boolean; +} + +export class Repository { + private readonly map: Map>; + private readonly listeners = new Set>(); + private readonly policy: FieldPolicy; + private readonly backend: StorageBackend; + 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) { + 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): () => 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[] { + 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 & Entity, src: FieldSource, at = Date.now()): T { + const existing = this.map.get(partial.id); + const base: StoredEntity = 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 = { + 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 & 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): 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): 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 }; diff --git a/src/main/store/social.ts b/src/main/store/social.ts new file mode 100644 index 0000000..672c40c --- /dev/null +++ b/src/main/store/social.ts @@ -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; connected: boolean }; +} + +const subscribed = new WeakSet(); + +async function connectPipeline(vrc: VRChat): Promise { + 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, + profile = false, +): (Partial & { id: string }) | null { + const id = (u.id ?? u.userId) as string | undefined; + if (!id) return null; + const p: Partial & { 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 { + return (data && typeof data === "object" ? data : {}) as Record; +} + +function userOf(data: unknown): Record { + 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 { + 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); + }); +} diff --git a/src/main/store/worldStore.ts b/src/main/store/worldStore.ts new file mode 100644 index 0000000..b2bbb0e --- /dev/null +++ b/src/main/store/worldStore.ts @@ -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(); + private byAuthor = new Map>(); + 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(); diff --git a/src/main/vrchat/authService.ts b/src/main/vrchat/authService.ts new file mode 100644 index 0000000..524cb2e --- /dev/null +++ b/src/main/vrchat/authService.ts @@ -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; + throwOnError?: boolean; + }) => Promise; + getCurrentUser: (opts?: { throwOnError?: boolean }) => Promise<{ data?: unknown }>; + logout?: () => Promise; +} + +type RawUser = Parameters[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 { + 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; +} | null = null; + +async function finalizeLogin(vrc: VRChatLike): Promise { + 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 { + 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 { + clearPending(); + const vrc = createLoginClient() as unknown as VRChatLike; + + let resolveCode!: (code: string) => void; + let rejectCode!: (err: unknown) => void; + const codePromise = new Promise((res, rej) => { + resolveCode = res; + rejectCode = rej; + }); + + let signalAwaiting!: (methods: TwoFactorMethod[]) => void; + const awaiting = new Promise((res) => { + signalAwaiting = res; + }); + + const loginDone: Promise = 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 { + 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 { + 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 { + 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); + } +} diff --git a/src/main/vrchat/avatarService.ts b/src/main/vrchat/avatarService.ts new file mode 100644 index 0000000..cdf635d --- /dev/null +++ b/src/main/vrchat/avatarService.ts @@ -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 { + 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 { + 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; +} diff --git a/src/main/vrchat/cachedRead.ts b/src/main/vrchat/cachedRead.ts new file mode 100644 index 0000000..453cdbe --- /dev/null +++ b/src/main/vrchat/cachedRead.ts @@ -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( + key: string, + policy: CachePolicy, + load: (vrc: VRChat) => Promise, +): Promise { + const vrc = requireActiveClient(); + return userCache.get(key, policy, () => load(vrc)); +} diff --git a/src/main/vrchat/client.ts b/src/main/vrchat/client.ts new file mode 100644 index 0000000..504d9a7 --- /dev/null +++ b/src/main/vrchat/client.ts @@ -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(); +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[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 { + 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; +} diff --git a/src/main/vrchat/cookies.ts b/src/main/vrchat/cookies.ts new file mode 100644 index 0000000..37115a2 --- /dev/null +++ b/src/main/vrchat/cookies.ts @@ -0,0 +1,50 @@ +import { session } from "electron"; + +interface RawCookie { + name: string; + value: string; + expires?: number | null; +} +interface CookieSource { + getCookies?: () => Promise; +} + +const AUTH_COOKIES = new Set(["auth", "twoFactorAuth"]); + +export async function syncSessionCookies(vrc: unknown): Promise { + 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 { + const jar = session.defaultSession.cookies; + for (const name of AUTH_COOKIES) { + try { + await jar.remove("https://api.vrchat.cloud", name); + } catch {} + } +} diff --git a/src/main/vrchat/errors.ts b/src/main/vrchat/errors.ts new file mode 100644 index 0000000..6594cd3 --- /dev/null +++ b/src/main/vrchat/errors.ts @@ -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 }; + headers?: Record; + 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(fn: () => Promise): Promise> { + try { + return { ok: true, data: await fn() }; + } catch (err) { + return { ok: false, error: toApiError(err) }; + } +} diff --git a/src/main/vrchat/friendsService.ts b/src/main/vrchat/friendsService.ts new file mode 100644 index 0000000..4c83e40 --- /dev/null +++ b/src/main/vrchat/friendsService.ts @@ -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 = { + "join me": 0, + active: 1, + "ask me": 2, + busy: 3, + offline: 4, +}; + +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 }), + ]); + 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), + ); + }); +} diff --git a/src/main/vrchat/groupService.ts b/src/main/vrchat/groupService.ts new file mode 100644 index 0000000..5b3d3c9 --- /dev/null +++ b/src/main/vrchat/groupService.ts @@ -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 { + 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 { + return cachedRead(cacheKeys.representedGroup(userId), policies.representedGroup, async (vrc) => { + const { data } = await vrc.getUserRepresentedGroup({ path: { userId }, throwOnError: true }); + return data?.groupId ? toGroup(data) : null; + }); +} diff --git a/src/main/vrchat/mappers.ts b/src/main/vrchat/mappers.ts new file mode 100644 index 0000000..3e97003 --- /dev/null +++ b/src/main/vrchat/mappers.ts @@ -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; +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), + }; +} diff --git a/src/main/vrchat/rawEndpoints.ts b/src/main/vrchat/rawEndpoints.ts new file mode 100644 index 0000000..8cafb2d --- /dev/null +++ b/src/main/vrchat/rawEndpoints.ts @@ -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 { + const worlds: FavoritedWorld[] = []; + const pageSize = 100; + for (let offset = 0; ; offset += pageSize) { + const { data } = await vrc.client.get({ + 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; +} diff --git a/src/main/vrchat/settingsService.ts b/src/main/vrchat/settingsService.ts new file mode 100644 index 0000000..ea531a1 --- /dev/null +++ b/src/main/vrchat/settingsService.ts @@ -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 { + const { data } = await vrc.getCurrentUser({ throwOnError: true }); + if ("requiresTwoFactorAuth" in data) throw { status: 401, message: "Session expired" }; + return data; +} + +export async function getSettings(): Promise { + return toSettings(await fetchCurrentUser(requireActiveClient())); +} + +type UpdateBody = Parameters[0]["body"]; + +async function patch(body: UpdateBody): Promise { + 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 { + return patch({ displayName, currentPassword }); +} + +export function revertDisplayName(currentPassword: string): Promise { + return patch({ revertDisplayName: true, currentPassword }); +} + +export function setEmail(email: string, currentPassword: string): Promise { + return patch({ email, currentPassword }); +} + +export function setPassword( + currentPassword: string, + newPassword: string, +): Promise { + return patch({ currentPassword, password: newPassword }); +} + +export function setPrivacy(p: { + sharedConnectionsHidden?: boolean; + discordFriendsHidden?: boolean; +}): Promise { + const body: Record = {}; + 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 { + 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 { + const ordered = CONTENT_FILTER_KEYS.filter((k) => filters.includes(k)); + return patch({ contentFilters: ordered }); +} + +export async function beginTwoFactorSetup(): Promise { + 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 { + 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 { + 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 { + const vrc = requireActiveClient(); + const me = await fetchCurrentUser(vrc); + await vrc.deleteAllUserPersistenceData({ path: { userId: me.id }, throwOnError: true }); +} + +export async function deleteAccount(): Promise { + const vrc = requireActiveClient(); + const me = await fetchCurrentUser(vrc); + const { data } = await vrc.deleteUser({ path: { userId: me.id }, throwOnError: true }); + invalidateSelf(); + return toSettings(data); +} diff --git a/src/main/vrchat/userService.ts b/src/main/vrchat/userService.ts new file mode 100644 index 0000000..4732b14 --- /dev/null +++ b/src/main/vrchat/userService.ts @@ -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 { + return (await currentUser()).id; +} + +function dropPresence(p: UserProfile): Partial & { 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 { + 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 { + 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 { + 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 { + 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)); + }); +} diff --git a/src/main/vrchat/worldService.ts b/src/main/vrchat/worldService.ts new file mode 100644 index 0000000..37e12ee --- /dev/null +++ b/src/main/vrchat/worldService.ts @@ -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 { + 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 { + const { worlds, folders } = await cachedRead( + 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 { + 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(); + const members: { id: string; group: string }[] = []; + const names = new Map(); + 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, +): FavoriteWorldFolder[] { + const order: string[] = []; + const byGroup = new Map(); + 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 { + 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 { + 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; +} diff --git a/src/main/windows.ts b/src/main/windows.ts new file mode 100644 index 0000000..5ac6d49 --- /dev/null +++ b/src/main/windows.ts @@ -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(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"); +} diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts new file mode 100644 index 0000000..8c43ddd --- /dev/null +++ b/src/preload/index.d.ts @@ -0,0 +1,9 @@ +import type { VrcCircleApi } from "./index"; + +declare global { + interface Window { + api: VrcCircleApi; + } +} + +export {}; diff --git a/src/preload/index.ts b/src/preload/index.ts new file mode 100644 index 0000000..154d8ce --- /dev/null +++ b/src/preload/index.ts @@ -0,0 +1,32 @@ +import { contextBridge, ipcRenderer } from "electron"; +import type { IpcRequests, IpcEvents } from "../shared/ipc"; + +type InvokeFn = ( + channel: C, + ...args: Parameters +) => ReturnType; + +export interface EventApi { + on(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); diff --git a/src/renderer/index.html b/src/renderer/index.html new file mode 100644 index 0000000..6456fe9 --- /dev/null +++ b/src/renderer/index.html @@ -0,0 +1,16 @@ + + + + + + + VRC Circle + + +
+ + + diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx new file mode 100644 index 0000000..f457202 --- /dev/null +++ b/src/renderer/src/App.tsx @@ -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 ; + + const authed = status.state === "authenticated"; + return authed && !adding ? : ; +} diff --git a/src/renderer/src/components/AppShell.tsx b/src/renderer/src/components/AppShell.tsx new file mode 100644 index 0000000..27848ec --- /dev/null +++ b/src/renderer/src/components/AppShell.tsx @@ -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 ( + + + + ); +} + +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 ( +
+
+
+ + + + VRC Circle +
+
+ +
+
+ +
+ + +
+ {nav.canBack ? ( + + ) : null} +
+ {nav.current.kind === "world" ? ( + + ) : nav.current.kind === "account" ? ( + + ) : nav.current.kind === "settings" ? ( + + ) : nav.current.kind === "enhancements" ? ( + + ) : nav.current.kind === "gallery" ? ( + + ) : nav.current.kind === "search" ? ( + + ) : ( + + )} +
+
+ + + + + + +
+
+ ); +} diff --git a/src/renderer/src/components/ui/Avatar.tsx b/src/renderer/src/components/ui/Avatar.tsx new file mode 100644 index 0000000..ae2bf59 --- /dev/null +++ b/src/renderer/src/components/ui/Avatar.tsx @@ -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 ? ( + + ) : ( + + {initials(name)} + + ); +} diff --git a/src/renderer/src/components/ui/Badge.tsx b/src/renderer/src/components/ui/Badge.tsx new file mode 100644 index 0000000..f6b9d74 --- /dev/null +++ b/src/renderer/src/components/ui/Badge.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/renderer/src/components/ui/Banner.tsx b/src/renderer/src/components/ui/Banner.tsx new file mode 100644 index 0000000..566ee3b --- /dev/null +++ b/src/renderer/src/components/ui/Banner.tsx @@ -0,0 +1,16 @@ +import type { ReactNode } from "react"; + +export function Banner({ children, className = "" }: { children: ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} diff --git a/src/renderer/src/components/ui/Button.tsx b/src/renderer/src/components/ui/Button.tsx new file mode 100644 index 0000000..e24c8b9 --- /dev/null +++ b/src/renderer/src/components/ui/Button.tsx @@ -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 & { + variant?: keyof typeof VARIANT; + loading?: boolean; + block?: boolean; +}) { + return ( + + ); +} + +export function IconButton({ + active, + className = "", + children, + ...rest +}: ButtonHTMLAttributes & { active?: boolean }) { + return ( + + ); +} diff --git a/src/renderer/src/components/ui/CollapsibleCard.tsx b/src/renderer/src/components/ui/CollapsibleCard.tsx new file mode 100644 index 0000000..2692ade --- /dev/null +++ b/src/renderer/src/components/ui/CollapsibleCard.tsx @@ -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 ( +
+ + {open ? children : null} +
+ ); +} diff --git a/src/renderer/src/components/ui/Field.tsx b/src/renderer/src/components/ui/Field.tsx new file mode 100644 index 0000000..288bd3d --- /dev/null +++ b/src/renderer/src/components/ui/Field.tsx @@ -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 & { label: string; hint?: ReactNode }) { + return ( + + ); +} diff --git a/src/renderer/src/components/ui/LinkPill.tsx b/src/renderer/src/components/ui/LinkPill.tsx new file mode 100644 index 0000000..75ed333 --- /dev/null +++ b/src/renderer/src/components/ui/LinkPill.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from "react"; + +export function LinkPill({ href, children }: { href: string; children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/src/renderer/src/components/ui/Loader.tsx b/src/renderer/src/components/ui/Loader.tsx new file mode 100644 index 0000000..67e04da --- /dev/null +++ b/src/renderer/src/components/ui/Loader.tsx @@ -0,0 +1,11 @@ +import { CircleDot } from "lucide-react"; + +export function Loader({ size = 40, className = "" }: { size?: number; className?: string }) { + return ( +
+ + + +
+ ); +} diff --git a/src/renderer/src/components/ui/Modal.tsx b/src/renderer/src/components/ui/Modal.tsx new file mode 100644 index 0000000..4bad06f --- /dev/null +++ b/src/renderer/src/components/ui/Modal.tsx @@ -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( +
+ +
+ +
{children}
+ + {onConfirm ? ( +
+ + +
+ ) : null} + + , + document.body, + ); +} diff --git a/src/renderer/src/components/ui/Panel.tsx b/src/renderer/src/components/ui/Panel.tsx new file mode 100644 index 0000000..8690d7f --- /dev/null +++ b/src/renderer/src/components/ui/Panel.tsx @@ -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 ( +
+
+

{title}

+ {meta ? {meta} : null} + {action ?
{action}
: null} +
+ {children} +
+ ); +} diff --git a/src/renderer/src/components/ui/PresenceAvatar.tsx b/src/renderer/src/components/ui/PresenceAvatar.tsx new file mode 100644 index 0000000..ebfd436 --- /dev/null +++ b/src/renderer/src/components/ui/PresenceAvatar.tsx @@ -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 ( + + + + + ); +} diff --git a/src/renderer/src/components/ui/Section.tsx b/src/renderer/src/components/ui/Section.tsx new file mode 100644 index 0000000..dab91bd --- /dev/null +++ b/src/renderer/src/components/ui/Section.tsx @@ -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 ( +
+ {collapsible ? ( + + ) : ( +

{title}

+ )} + {open ?
{children}
: null} +
+ ); +} + +export function Fact({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} diff --git a/src/renderer/src/components/ui/SkeletonGrid.tsx b/src/renderer/src/components/ui/SkeletonGrid.tsx new file mode 100644 index 0000000..af2abe2 --- /dev/null +++ b/src/renderer/src/components/ui/SkeletonGrid.tsx @@ -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 ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+ ))} +
+ ); +} diff --git a/src/renderer/src/components/ui/Stat.tsx b/src/renderer/src/components/ui/Stat.tsx new file mode 100644 index 0000000..32f5d1c --- /dev/null +++ b/src/renderer/src/components/ui/Stat.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; + +export function Stat({ + label, + value, + tone, +}: { + label: ReactNode; + value: ReactNode; + tone?: string; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} diff --git a/src/renderer/src/components/ui/StatTile.tsx b/src/renderer/src/components/ui/StatTile.tsx new file mode 100644 index 0000000..6108393 --- /dev/null +++ b/src/renderer/src/components/ui/StatTile.tsx @@ -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 ( +
+
+ {icon} {label} +
+
+ {value} +
+
+ ); +} diff --git a/src/renderer/src/components/ui/StatusDot.tsx b/src/renderer/src/components/ui/StatusDot.tsx new file mode 100644 index 0000000..5f79fba --- /dev/null +++ b/src/renderer/src/components/ui/StatusDot.tsx @@ -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) { + return ( + + ); +} diff --git a/src/renderer/src/components/ui/Tabs.tsx b/src/renderer/src/components/ui/Tabs.tsx new file mode 100644 index 0000000..2cbd78d --- /dev/null +++ b/src/renderer/src/components/ui/Tabs.tsx @@ -0,0 +1,33 @@ +export function Tabs({ + tabs, + active, + onChange, +}: { + tabs: { id: T; label: string }[]; + active: T; + onChange: (id: T) => void; +}) { + return ( +
+ {tabs.map((t) => { + const selected = t.id === active; + return ( + + ); + })} +
+ ); +} diff --git a/src/renderer/src/components/ui/Tag.tsx b/src/renderer/src/components/ui/Tag.tsx new file mode 100644 index 0000000..4ef8ad2 --- /dev/null +++ b/src/renderer/src/components/ui/Tag.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from "react"; + +export function Tag({ children, color }: { children: ReactNode; color?: string }) { + const c = color ?? "var(--muted)"; + return ( + + {children} + + ); +} diff --git a/src/renderer/src/components/ui/Toggle.tsx b/src/renderer/src/components/ui/Toggle.tsx new file mode 100644 index 0000000..127cb0e --- /dev/null +++ b/src/renderer/src/components/ui/Toggle.tsx @@ -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 ( + + ); +} diff --git a/src/renderer/src/components/ui/index.ts b/src/renderer/src/components/ui/index.ts new file mode 100644 index 0000000..c5d8dd5 --- /dev/null +++ b/src/renderer/src/components/ui/index.ts @@ -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"; diff --git a/src/renderer/src/features/account/AccountSettingsView.tsx b/src/renderer/src/features/account/AccountSettingsView.tsx new file mode 100644 index 0000000..23f08a7 --- /dev/null +++ b/src/renderer/src/features/account/AccountSettingsView.tsx @@ -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("account"); + + if (state.status === "loading") return ; + if (state.status === "error") + return ( +
+ {state.message} +
+ ); + + const s = state.settings; + return ( +
+
+

{t("account:title")}

+

+ ]} + /> +

+
+ + + + {tab === "account" ? ( +
+ + + + +
+ ) : null} + + {tab === "security" ? ( +
+ + +
+ ) : null} + + {tab === "privacy" ? ( +
+ + +
+ ) : null} + + {tab === "data" ? ( +
+ + +
+ ) : null} +
+ ); +} diff --git a/src/renderer/src/features/account/sections/AccountLinksSection.tsx b/src/renderer/src/features/account/sections/AccountLinksSection.tsx new file mode 100644 index 0000000..d8526ac --- /dev/null +++ b/src/renderer/src/features/account/sections/AccountLinksSection.tsx @@ -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 ( +
+
+ + +
+
+ {t("account:linkedAccounts.manage")} +
+
+ ); +} + +function LinkRow({ name, link }: { name: string; link: AccountSettings["discord"] }) { + const { t } = useI18n(); + return ( +
+
+
{name}
+ {link.linked && link.label ? ( +
{link.label}
+ ) : null} +
+ {link.linked ? ( + {t("account:linkedAccounts.linked")} + ) : ( + {t("account:linkedAccounts.notLinked")} + )} +
+ ); +} diff --git a/src/renderer/src/features/account/sections/AgeVerificationSection.tsx b/src/renderer/src/features/account/sections/AgeVerificationSection.tsx new file mode 100644 index 0000000..3a93f0c --- /dev/null +++ b/src/renderer/src/features/account/sections/AgeVerificationSection.tsx @@ -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 ( +
+
+ {verified ? ( + + {settings.ageVerificationStatus === "18+" + ? t("account:ageVerification.verified18") + : t("account:ageVerification.verified")} + + ) : ( + {t("account:ageVerification.notVerified")} + )} + + {verified ? t("account:ageVerification.manage") : t("account:ageVerification.verify")} + +
+
+ ); +} diff --git a/src/renderer/src/features/account/sections/ContentGatingSection.tsx b/src/renderer/src/features/account/sections/ContentGatingSection.tsx new file mode 100644 index 0000000..e276e0f --- /dev/null +++ b/src/renderer/src/features/account/sections/ContentGatingSection.tsx @@ -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 ( +
+ {settings.contentFiltersLocked ? ( +

{t("account:contentGating.locked")}

+ ) : null} +
+ {FILTER_ORDER.map((key) => ( + toggle(key, on)} + disabled={busy || settings.contentFiltersLocked} + /> + ))} +
+ +
+ ); +} diff --git a/src/renderer/src/features/account/sections/DangerZoneSection.tsx b/src/renderer/src/features/account/sections/DangerZoneSection.tsx new file mode 100644 index 0000000..b0c7669 --- /dev/null +++ b/src/renderer/src/features/account/sections/DangerZoneSection.tsx @@ -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 ( +
} danger> +

+ ]} + /> +

+
+ ); + } + + return ( +
} + description={t("account:dangerZone.description")} + danger + > +
+ setConfirmText(e.target.value)} + /> + +
+ +
+ ); +} diff --git a/src/renderer/src/features/account/sections/DisplayNameSection.tsx b/src/renderer/src/features/account/sections/DisplayNameSection.tsx new file mode 100644 index 0000000..ca88072 --- /dev/null +++ b/src/renderer/src/features/account/sections/DisplayNameSection.tsx @@ -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 ( +
+ {inCooldown ? ( + + ) : null} + +
+ setName(e.target.value)} + /> + setPassword(e.target.value)} + /> +
+
+ {!inCooldown ? ( + + ) : null} + {canRevert ? ( + + ) : null} + {canRevert && !password ? ( + + {t("account:displayName.enableRevertHint")} + + ) : null} + {!inCooldown && lastChangedLabel(t, settings.displayNameChangedAt) ? ( + + {lastChangedLabel(t, settings.displayNameChangedAt)} + + ) : null} +
+ + setConfirmRevert(false)} + title={t("account:displayName.revertModal.title")} + icon={} + confirmLabel={t("account:displayName.revertModal.confirm")} + onConfirm={revert} + confirmLoading={busy} + > + , + , + ]} + /> + +
+ ); +} + +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 ( +
+ +
+
{t("account:displayName.cooldown.heading")}
+

+ , + , + , + ]} + /> +

+ {canRevert ? ( +

{t("account:displayName.cooldown.canRevert")}

+ ) : null} + {!supporter ? ( +

{t("account:displayName.cooldown.upsell")}

+ ) : null} +
+
+ ); +} diff --git a/src/renderer/src/features/account/sections/EmailSection.tsx b/src/renderer/src/features/account/sections/EmailSection.tsx new file mode 100644 index 0000000..32d831e --- /dev/null +++ b/src/renderer/src/features/account/sections/EmailSection.tsx @@ -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 ( +
+ {t("account:email.current")} + {settings.email || t("account:email.none")} + {settings.emailVerified ? null : t("account:email.unverified")} + {settings.pendingEmail ? ( + <> + {t("account:email.pending")} + {settings.pendingEmail} + + ) : null} + + } + > +
+ setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> +
+
+ +
+ +
+ ); +} diff --git a/src/renderer/src/features/account/sections/PasswordSection.tsx b/src/renderer/src/features/account/sections/PasswordSection.tsx new file mode 100644 index 0000000..5eaaa37 --- /dev/null +++ b/src/renderer/src/features/account/sections/PasswordSection.tsx @@ -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 ( +
+ {t("account:password.manageSignIn")} +
+ ); + } + + return ( +
+
+ setCurrent(e.target.value)} + /> + setNext(e.target.value)} + /> + setConfirm(e.target.value)} + hint={mismatch ? t("account:password.mismatch") : undefined} + /> +
+
+ +
+ +
+ ); +} diff --git a/src/renderer/src/features/account/sections/PrivacySection.tsx b/src/renderer/src/features/account/sections/PrivacySection.tsx new file mode 100644 index 0000000..3e22bab --- /dev/null +++ b/src/renderer/src/features/account/sections/PrivacySection.tsx @@ -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 ( +
+
+ + +
+ +
+ ); +} diff --git a/src/renderer/src/features/account/sections/TwoFactorSection.tsx b/src/renderer/src/features/account/sections/TwoFactorSection.tsx new file mode 100644 index 0000000..f0661e4 --- /dev/null +++ b/src/renderer/src/features/account/sections/TwoFactorSection.tsx @@ -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(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 ( +
} + description={t("account:twoFactor.description")} + > +
+ {settings.twoFactorEnabled ? ( + {t("account:twoFactor.enabled")} + ) : ( + {t("account:twoFactor.disabled")} + )} + {settings.twoFactorEnabledDate ? ( + + {t("account:twoFactor.since", { + date: new Date(settings.twoFactorEnabledDate).toLocaleDateString(), + })} + + ) : null} +
+ + {settings.twoFactorEnabled ? ( +
+
+ + +
+ {codes ? ( +
+
+ + {t("account:twoFactor.recoveryCodes")} + + +
+
    + {codes.map((c) => ( +
  • + {c.code} +
  • + ))} +
+

{t("account:twoFactor.storeSafely")}

+
+ ) : null} + + setConfirmDisable(false)} + title={t("account:twoFactor.disableModal.title")} + icon={} + danger + confirmLabel={t("account:twoFactor.disableModal.confirm")} + onConfirm={turnOff} + confirmLoading={stepUp.busy} + > + {t("account:twoFactor.disableModal.body")} + + +
+ ) : pending ? ( +
+ {t("account:twoFactor.qrAlt")} +
+

{t("account:twoFactor.scanHint")}

+ + {pending.secret} + +
+ setCode(e.target.value)} + /> + +
+ +
+
+ ) : ( +
+ + +
+ )} +
+ ); +} diff --git a/src/renderer/src/features/account/sections/UserDataSection.tsx b/src/renderer/src/features/account/sections/UserDataSection.tsx new file mode 100644 index 0000000..a456d70 --- /dev/null +++ b/src/renderer/src/features/account/sections/UserDataSection.tsx @@ -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 ( +
+ {armed ? ( +
+ {t("account:userData.confirmPrompt")} + + +
+ ) : ( + + )} + +
+ ); +} diff --git a/src/renderer/src/features/account/ui.tsx b/src/renderer/src/features/account/ui.tsx new file mode 100644 index 0000000..a507054 --- /dev/null +++ b/src/renderer/src/features/account/ui.tsx @@ -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["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 ( +
+
+

+ {icon ? {icon} : null} + {title} +

+ {description ?

{description}

: null} +
+ {children} +
+ ); +} + +export function useAsync() { + const { t } = useI18n(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [ok, setOk] = useState(null); + + async function run(p: Promise, 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 ( +

+ {error} +

+ ); + if (ok) + return ( +

+ {ok} +

+ ); + return null; +} + +export function ToggleRow({ + label, + hint, + checked, + onChange, + disabled, +}: { + label: string; + hint?: ReactNode; + checked: boolean; + onChange: (v: boolean) => void; + disabled?: boolean; +}) { + return ( +
+
+
{label}
+ {hint ?
{hint}
: null} +
+ +
+ ); +} + +export function ExternalButton({ href, children }: { href: string; children: ReactNode }) { + return ( + + {children} + + + ); +} + +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 }); +} diff --git a/src/renderer/src/features/account/useAccountSettings.ts b/src/renderer/src/features/account/useAccountSettings.ts new file mode 100644 index 0000000..0afa9f4 --- /dev/null +++ b/src/renderer/src/features/account/useAccountSettings.ts @@ -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({ 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 }; +} diff --git a/src/renderer/src/features/auth/AccountSwitcher.tsx b/src/renderer/src/features/auth/AccountSwitcher.tsx new file mode 100644 index 0000000..29a9c9a --- /dev/null +++ b/src/renderer/src/features/auth/AccountSwitcher.tsx @@ -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 ( +
+ {open ?
: null} + + + + {open ? ( +
+ + + {others.length && showAccounts ? ( + <> + +
+ {others.map((a) => ( +
+ + +
+ ))} +
+ + ) : null} + + +
+ {others.length ? ( + + ) : null} + + +
+
+ ) : null} +
+ ); +} + +function Separator() { + return
; +} + +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 ( +
+
+ {STATUS_CHOICES.map((s) => ( + + ))} +
+
{ + e.preventDefault(); + if (current !== "offline" && dirty) void apply(current, desc); + }} + > + setDesc(e.target.value)} + /> + +
+
+ ); +} diff --git a/src/renderer/src/features/auth/AuthContext.tsx b/src/renderer/src/features/auth/AuthContext.tsx new file mode 100644 index 0000000..78856eb --- /dev/null +++ b/src/renderer/src/features/auth/AuthContext.tsx @@ -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; + verify2fa: (method: TwoFactorMethod, code: string) => Promise; + logout: () => Promise; + switchAccount: (id: string) => Promise; + removeAccount: (id: string) => Promise; + beginAddAccount: () => void; + cancelAddAccount: () => void; +} + +const AuthContext = createContext(null); +const EMPTY: AccountsState = { accounts: [], activeId: null }; + +export function AuthProvider({ children }: { children: ReactNode }) { + const [status, setStatus] = useState({ state: "unauthenticated" }); + const [accounts, setAccounts] = useState(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 {children}; +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error("useAuth must be used within "); + return ctx; +} diff --git a/src/renderer/src/features/auth/LoginScreen.tsx b/src/renderer/src/features/auth/LoginScreen.tsx new file mode 100644 index 0000000..f86de8e --- /dev/null +++ b/src/renderer/src/features/auth/LoginScreen.tsx @@ -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 ( +
+
+
+ {canGoBack ? ( + + ) : null} + +
+
+ +
+

VRC Circle

+

+ {adding ? t("auth:addAccount") : t("auth:tagline")} +

+
+ + {awaiting2fa ? ( + + ) : ( + + )} + +
+ {t("auth:disclaimer")} +
+
+
+ ); +} + +function CredentialForm({ + onSubmit, +}: { + onSubmit: (username: string, password: string) => Promise; +}) { + const { t } = useI18n(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 ( +
+ setUsername(e.target.value)} + required + /> + setPassword(e.target.value)} + required + /> + {error ? {error} : null} + + + ); +} + +function TwoFactorForm({ + methods, + onSubmit, +}: { + methods: TwoFactorMethod[]; + onSubmit: (method: TwoFactorMethod, code: string) => Promise; +}) { + const { t } = useI18n(); + const [method, setMethod] = useState(methods[0] ?? "totp"); + const [code, setCode] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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 ( +
+

+ {t("auth:twoFactorPrompt", { + source: t( + method === "emailOtp" ? "auth:twoFactorEmailSource" : "auth:twoFactorAppSource", + ), + })} +

+ + {methods.length > 1 ? ( +
+ {methods.map((m) => ( + + ))} +
+ ) : null} + + setCode(e.target.value.replace(/\D/g, "").slice(0, 8))} + required + /> + {error ? {error} : null} + + + ); +} + +type Translate = ReturnType["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"); +} diff --git a/src/renderer/src/features/auth/TwoFactorPrompt.tsx b/src/renderer/src/features/auth/TwoFactorPrompt.tsx new file mode 100644 index 0000000..ffc0855 --- /dev/null +++ b/src/renderer/src/features/auth/TwoFactorPrompt.tsx @@ -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 ( + { + setCode(""); + onClose(); + }} + title="Verify it's you" + icon={} + confirmLabel="Verify" + onConfirm={submit} + confirmLoading={busy} + confirmDisabled={code.trim().length < 6} + > +

+ {method === "emailOtp" + ? "Enter the code we emailed you to continue." + : "Enter the code from your authenticator app to continue."} +

+
+ setCode(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && submit()} + /> +
+ {error ?

{error}

: null} +
+ ); +} diff --git a/src/renderer/src/features/auth/useStepUp.ts b/src/renderer/src/features/auth/useStepUp.ts new file mode 100644 index 0000000..0285879 --- /dev/null +++ b/src/renderer/src/features/auth/useStepUp.ts @@ -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; + } | null>(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function run(action: () => Promise): Promise { + 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)), + }; +} diff --git a/src/renderer/src/features/debug/DebugPanel.tsx b/src/renderer/src/features/debug/DebugPanel.tsx new file mode 100644 index 0000000..9668e33 --- /dev/null +++ b/src/renderer/src/features/debug/DebugPanel.tsx @@ -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("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 ( +
+
+ setTab("cache")}> + Cache + + setTab("repos")}> + Repositories + + setTab("thumbnails")}> + Thumbnails + + setTab("social")}> + Social + + setTab("worlds")}> + Worlds + + setTab("ws")}> + WebSocket + + setTab("logs")}> + Logs + + +
+ + {tab === "cache" ? ( + + ) : tab === "repos" ? ( + + ) : tab === "thumbnails" ? ( + + ) : tab === "social" ? ( + + ) : tab === "worlds" ? ( + + ) : tab === "ws" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/renderer/src/features/debug/DebugWindow.tsx b/src/renderer/src/features/debug/DebugWindow.tsx new file mode 100644 index 0000000..b0294da --- /dev/null +++ b/src/renderer/src/features/debug/DebugWindow.tsx @@ -0,0 +1,9 @@ +import { DebugPanel } from "./DebugPanel"; + +export function DebugWindow() { + return ( +
+ +
+ ); +} diff --git a/src/renderer/src/features/debug/tabs/CacheTab.tsx b/src/renderer/src/features/debug/tabs/CacheTab.tsx new file mode 100644 index 0000000..a85dc72 --- /dev/null +++ b/src/renderer/src/features/debug/tabs/CacheTab.tsx @@ -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("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(); + 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 ( +
+ {stats ? ( + +
+ + + + + + + + + + + + +
+
+ ) : null} + + + Clear all + + } + className="min-h-0 flex-1" + > +
+ +
+ {(["all", "fresh", "stale", "expired"] as CacheFilter[]).map((f) => ( + setFilter(f)}> + {f} + + ))} +
+
+ +
+ {shown.length === 0 ? ( + {cache.length === 0 ? "Cache is empty." : "No entries match."} + ) : ( + groups.map(([group, entries]) => ( +
+
+ {group} + {entries.length} +
+ {entries.map((e) => ( + onInvalidate(e.key)} + /> + ))} +
+ )) + )} +
+
+
+ ); +} + +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 ( +
+
+ + + + {entry.hits} hits + {formatBytes(entry.size)} + {formatDuration(age)} old + + {status === "fresh" ? formatDuration(Math.round(remaining / 1000)) : status} + + + + +
+ +
+
+
+
+
+ + {open ? ( +
+
+ + + + +
+
+ +
+              {JSON.stringify(entry.value, null, 2)}
+            
+
+
+ ) : null} +
+ ); +} diff --git a/src/renderer/src/features/debug/tabs/LogsTab.tsx b/src/renderer/src/features/debug/tabs/LogsTab.tsx new file mode 100644 index 0000000..467a013 --- /dev/null +++ b/src/renderer/src/features/debug/tabs/LogsTab.tsx @@ -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 = { + 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("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 ( + + Clear + + } + > +
+ +
+ {LEVELS.map((l) => ( + setMin(l)}> + ≥ {l} + + ))} +
+
+ +
+ {shown.length === 0 ? ( + No logs match. + ) : ( + shown + .slice() + .reverse() + .map((l) => ( +
+ {formatTime(l.ts)} + + {l.level} + + {l.scope} + {l.message} +
+ )) + )} +
+
+ ); +} diff --git a/src/renderer/src/features/debug/tabs/ReposTab.tsx b/src/renderer/src/features/debug/tabs/ReposTab.tsx new file mode 100644 index 0000000..c2732b2 --- /dev/null +++ b/src/renderer/src/features/debug/tabs/ReposTab.tsx @@ -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(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 setSelected(null)} now={now} />; + } + + return ( +
+ +
+ + + + 0 ? "var(--status-ask)" : undefined} + /> +
+ {dir ? ( +

Stored at {dir}

+ ) : null} +
+ + {repos.length === 0 ? ( + + No active account — repositories load when signed in. + + ) : ( +
+ {repos.map((r) => ( + setSelected(r.name)} /> + ))} +
+ )} +
+ ); +} + +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 ( + +
+ + 0 ? "var(--status-ask)" : undefined} + /> +
+
+ + +
+
+ + + +
+
+ ); +} + +function RepoInspector({ name, onBack, now }: { name: string; onBack: () => void; now: number }) { + const [entities, setEntities] = useState[]>([]); + 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 ( + + + +
+ } + > +
+ +
+
+ {shown.length === 0 ? ( + {entities.length === 0 ? "Repository is empty." : "No entities match."} + ) : ( + shown.map((e) => ) + )} +
+ + ); +} + +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; + const label = (data.name ?? data.displayName ?? data.id) as string; + const fieldNames = Object.keys(entity.meta.fields).sort(); + + return ( +
+ + {open ? ( +
+
+ + + + + + + + + + + {fieldNames.map((f) => { + const meta = entity.meta.fields[f]; + return ( + + + + + + + ); + })} + +
FieldValueSourceUpdated
{f} + {formatFieldValue(data[f])} + + {meta.src} + + {meta.at ? relativeAge(now, meta.at) : "stale"} +
+
+
+ + + +
+
+ +
+              {JSON.stringify(entity.data, null, 2)}
+            
+
+
+ ) : null} +
+ ); +} diff --git a/src/renderer/src/features/debug/tabs/SocialTab.tsx b/src/renderer/src/features/debug/tabs/SocialTab.tsx new file mode 100644 index 0000000..c5e370f --- /dev/null +++ b/src/renderer/src/features/debug/tabs/SocialTab.tsx @@ -0,0 +1,129 @@ +import { useMemo, useState } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import type { UserProfile } from "../../../../../shared/types/user"; +import { Badge, Panel, PresenceAvatar, Tag } from "../../../components/ui"; +import { statusMeta, trustMeta } from "../../../lib/vrchat"; +import { useSocial } from "../../../store/social"; +import { useCopied } from "../useDebug"; +import { Chip, Empty, Meta, SearchInput } from "../ui"; + +type SocialFilter = "all" | "self" | "friends" | "other"; + +export function SocialTab() { + const selfId = useSocial((s) => s.selfId); + const users = useSocial((s) => s.users); + const [query, setQuery] = useState(""); + + const [role, setRole] = useState("all"); + + const list = useMemo(() => Object.values(users), [users]); + const friends = list.filter((u) => u.isFriend); + const online = friends.filter((f) => f.status !== "offline").length; + + const shown = useMemo(() => { + const q = query.trim().toLowerCase(); + return [...list] + .filter((u) => + role === "self" + ? u.id === selfId + : role === "friends" + ? u.isFriend + : role === "other" + ? !u.isFriend && u.id !== selfId + : true, + ) + .filter((u) => (q ? u.displayName.toLowerCase().includes(q) || u.id.includes(q) : true)) + .sort( + (a, b) => + Number(b.id === selfId) - Number(a.id === selfId) || + Number(b.isFriend) - Number(a.isFriend) || + a.displayName.localeCompare(b.displayName), + ); + }, [list, query, role, selfId]); + + return ( + +
+ +
+ {(["all", "self", "friends", "other"] as SocialFilter[]).map((r) => ( + setRole(r)}> + {r} + + ))} +
+
+
+ {shown.length === 0 ? ( + + {list.length === 0 ? "Store is empty — sign in to seed it." : "No users match."} + + ) : ( + shown.map((u) => ) + )} +
+
+ ); +} + +function UserRow({ user, isSelf }: { user: UserProfile; isSelf: boolean }) { + const [open, setOpen] = useState(false); + const [copied, copy] = useCopied(); + const status = statusMeta[user.status]; + const trust = trustMeta[user.trustRank]; + + return ( +
+ + + {open ? ( +
+
+ + + + + + +
+ {user.statusDescription ? ( +

“{user.statusDescription}”

+ ) : null} +
+ +
+              {JSON.stringify(user, null, 2)}
+            
+
+
+ ) : null} +
+ ); +} diff --git a/src/renderer/src/features/debug/tabs/ThumbnailsTab.tsx b/src/renderer/src/features/debug/tabs/ThumbnailsTab.tsx new file mode 100644 index 0000000..a5a5c1e --- /dev/null +++ b/src/renderer/src/features/debug/tabs/ThumbnailsTab.tsx @@ -0,0 +1,58 @@ +import { useEffect, useState } from "react"; +import { Images, Trash2 } from "lucide-react"; +import type { ThumbCacheStats } from "../../../../../shared/types/gallery"; +import { Button, Panel, Stat } from "../../../components/ui"; +import { api } from "../../../lib/api"; +import { formatBytes } from "../ui"; + +export function ThumbnailsTab() { + const [stats, setStats] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + api.gallery + .thumbStats() + .then(setStats) + .catch(() => setStats(null)); + }, []); + + async function clear() { + setBusy(true); + try { + setStats(await api.gallery.thumbClear()); + } finally { + setBusy(false); + } + } + + return ( + + Clear cache + + } + > +
+ + +
+
+ + Downscaled JPEGs generated from your VRChat screenshots. Clearing them just frees disk; they + rebuild on next view. +
+
+ ); +} diff --git a/src/renderer/src/features/debug/tabs/WebSocketTab.tsx b/src/renderer/src/features/debug/tabs/WebSocketTab.tsx new file mode 100644 index 0000000..836711c --- /dev/null +++ b/src/renderer/src/features/debug/tabs/WebSocketTab.tsx @@ -0,0 +1,83 @@ +import { useMemo, useState } from "react"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import type { WsEvent } from "../../../../../shared/types/debug"; +import { Badge, Button, Panel } from "../../../components/ui"; +import { useCopied } from "../useDebug"; +import { Chip, Empty, SearchInput, formatTime } from "../ui"; + +export function WsTab({ ws, onClear }: { ws: WsEvent[]; onClear: () => void }) { + const [query, setQuery] = useState(""); + const [onlyHandled, setOnlyHandled] = useState(false); + + const shown = useMemo(() => { + const q = query.trim().toLowerCase(); + return ws.filter( + (e) => (onlyHandled ? e.handled : true) && (q ? e.type.toLowerCase().includes(q) : true), + ); + }, [ws, query, onlyHandled]); + + return ( + + Clear + + } + > +
+ + setOnlyHandled((v) => !v)}> + handled only + +
+
+ {shown.length === 0 ? ( + {ws.length === 0 ? "No events yet" : "No events match."} + ) : ( + shown + .slice() + .reverse() + .map((e) => ) + )} +
+
+ ); +} + +function WsRow({ event }: { event: WsEvent }) { + const [open, setOpen] = useState(false); + const [copied, copy] = useCopied(); + return ( +
+ + {open ? ( +
+
+ +
+              {JSON.stringify(event.content, null, 2)}
+            
+
+
+ ) : null} +
+ ); +} diff --git a/src/renderer/src/features/debug/tabs/WorldsTab.tsx b/src/renderer/src/features/debug/tabs/WorldsTab.tsx new file mode 100644 index 0000000..dd15a54 --- /dev/null +++ b/src/renderer/src/features/debug/tabs/WorldsTab.tsx @@ -0,0 +1,90 @@ +import { useMemo, useState } from "react"; +import { ChevronDown, ChevronRight, Star } from "lucide-react"; +import type { World } from "../../../../../shared/types/world"; +import { Badge, Panel } from "../../../components/ui"; +import { useAllWorlds, useWorlds } from "../../../store/worlds"; +import { useCopied } from "../useDebug"; +import { Empty, SearchInput } from "../ui"; + +export function WorldStorePanel() { + const worlds = useAllWorlds(); + const byAuthor = useWorlds((s) => s.byAuthor); + const authorCount = Object.keys(byAuthor).length; + const [query, setQuery] = useState(""); + + const shown = useMemo(() => { + const q = query.trim().toLowerCase(); + return [...worlds] + .filter((w) => (q ? w.name.toLowerCase().includes(q) || w.id.includes(q) : true)) + .sort((a, b) => a.name.localeCompare(b.name)); + }, [worlds, query]); + + return ( + +
+ +
+
+ {shown.length === 0 ? ( + + {worlds.length === 0 + ? "No worlds yet — open a profile to seed it." + : "No worlds match."} + + ) : ( + shown.map((w) => ) + )} +
+
+ ); +} + +function WorldStoreRow({ world }: { world: World }) { + const [open, setOpen] = useState(false); + const [copied, copy] = useCopied(); + const img = world.thumbnailImageUrl || world.imageUrl; + return ( +
+ + {open ? ( +
+
+ +
+              {JSON.stringify(world, null, 2)}
+            
+
+
+ ) : null} +
+ ); +} diff --git a/src/renderer/src/features/debug/ui.tsx b/src/renderer/src/features/debug/ui.tsx new file mode 100644 index 0000000..37f580a --- /dev/null +++ b/src/renderer/src/features/debug/ui.tsx @@ -0,0 +1,155 @@ +import { X } from "lucide-react"; +import type { CacheEntryInfo, CacheStatus } from "../../../../shared/types/debug"; + +export const statusTone = { + fresh: "success", + stale: "warn", + expired: "danger", +} as const; +export const statusColor = { + fresh: "var(--status-active)", + stale: "var(--status-ask)", + expired: "var(--danger)", +} as const; + +const SOURCE_COLOR: Record = { + ws: "var(--status-active)", + "rest:detail": "var(--accent)", + "rest:list": "var(--muted)", + "rest:search": "var(--faint)", + seed: "var(--faint)", +}; +export function sourceColor(src: string): string { + return SOURCE_COLOR[src] ?? "var(--muted)"; +} + +export function formatFieldValue(v: unknown): string { + if (v === null) return "null"; + if (v === undefined) return "—"; + if (Array.isArray(v)) return `[${v.length}]`; + if (typeof v === "object") return "{…}"; + return String(v); +} + +export function relativeAge(now: number, ts: number): string { + if (!ts) return "stale"; + const s = Math.max(0, Math.round((now - ts) / 1000)); + if (s < 60) return `${s}s ago`; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + return `${Math.floor(s / 86400)}d ago`; +} + +export function cacheStatus(e: CacheEntryInfo, now: number): CacheStatus { + return now < e.expiresAt ? "fresh" : now < e.hardExpiresAt ? "stale" : "expired"; +} + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(1)} MB`; +} + +export function formatDuration(s: number): string { + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`; + return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`; +} + +export function formatTime(ts: number): string { + return new Date(ts).toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +export function TabButton({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +export function Count({ n }: { n: number }) { + return {n}; +} + +export function Chip({ + active, + onClick, + children, +}: { + active: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +export function SearchInput({ + value, + onChange, + placeholder, +}: { + value: string; + onChange: (v: string) => void; + placeholder: string; +}) { + return ( +
+ onChange(e.target.value)} + placeholder={placeholder} + className="w-full rounded-md border border-border bg-surface-2 px-3 py-1.5 text-xs text-text placeholder:text-faint focus:border-accent focus:outline-none" + /> + {value ? ( + + ) : null} +
+ ); +} + +export function Meta({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +export function Empty({ children }: { children: React.ReactNode }) { + return

{children}

; +} diff --git a/src/renderer/src/features/debug/useDebug.ts b/src/renderer/src/features/debug/useDebug.ts new file mode 100644 index 0000000..d5aacba --- /dev/null +++ b/src/renderer/src/features/debug/useDebug.ts @@ -0,0 +1,85 @@ +import { useEffect, useRef, useState } from "react"; +import type { CacheEntryInfo, CacheStats, LogEntry, WsEvent } from "../../../../shared/types/debug"; +import type { RepoStats } from "../../../../shared/types/repository"; +import { api, events } from "../../lib/api"; + +const MAX_LOGS = 500; + +export function useNow(ms = 1000): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), ms); + return () => clearInterval(id); + }, [ms]); + return now; +} + +export function useCopied(): [boolean, (text: string) => void] { + const [copied, setCopied] = useState(false); + const timer = useRef>(undefined); + const copy = (text: string): void => { + void navigator.clipboard.writeText(text).then(() => { + setCopied(true); + clearTimeout(timer.current); + timer.current = setTimeout(() => setCopied(false), 1200); + }); + }; + return [copied, copy]; +} + +export function useDebug() { + const [cache, setCache] = useState([]); + const [stats, setStats] = useState(null); + const [logs, setLogs] = useState([]); + const [ws, setWs] = useState([]); + const [repoStats, setRepoStats] = useState([]); + + useEffect(() => { + let active = true; + api.debug.snapshot().then((s) => { + if (!active) return; + setCache(s.cache); + setStats(s.stats); + setLogs(s.logs); + setWs(s.ws); + setRepoStats(s.repos); + }); + + const pollRepos = () => api.debug.repoStats().then((r) => active && setRepoStats(r)); + const repoTimer = setInterval(pollRepos, 2000); + + const offCache = events.on("debug:cache", (u) => { + setCache(u.cache); + setStats(u.stats); + }); + const offLog = events.on("debug:log", (e) => setLogs((prev) => [...prev, e].slice(-MAX_LOGS))); + const offWs = events.on("ws:event", (e) => setWs((prev) => [...prev, e].slice(-MAX_LOGS))); + return () => { + active = false; + clearInterval(repoTimer); + offCache(); + offLog(); + offWs(); + }; + }, []); + + return { + cache, + stats, + logs, + ws, + repoStats, + clearLogs: () => setLogs([]), + clearWs: () => setWs([]), + invalidate: (key: string) => + api.debug.cacheInvalidate(key).then((u) => { + setCache(u.cache); + setStats(u.stats); + }), + clear: () => + api.debug.cacheClear().then((u) => { + setCache(u.cache); + setStats(u.stats); + }), + }; +} diff --git a/src/renderer/src/features/enhancements/EnhancementsView.tsx b/src/renderer/src/features/enhancements/EnhancementsView.tsx new file mode 100644 index 0000000..79ca1fa --- /dev/null +++ b/src/renderer/src/features/enhancements/EnhancementsView.tsx @@ -0,0 +1,158 @@ +import { useEffect, useState, type ReactNode } from "react"; +import { AlertTriangle, Camera } from "lucide-react"; +import type { + EnhancementId, + EnhancementState, + EnhancementsSnapshot, + OsPlatform, +} from "../../../../shared/types/enhancements"; +import { api, errorMessage } from "../../lib/api"; +import { useI18n } from "../../lib/i18n"; +import { Badge, Banner, Loader, Toggle } from "../../components/ui"; + +const SHELL = "mx-auto flex w-full max-w-[760px] flex-col gap-[18px] px-12 pb-16 pt-10"; + +type Meta = { + id: EnhancementId; + icon: ReactNode; + platforms: OsPlatform[]; +}; + +const CATALOG: Meta[] = [ + { + id: "linux-screenshot-symlink", + icon: , + platforms: ["linux"], + }, +]; + +const OS_LABEL: Record = { + linux: "Linux", + win32: "Windows", + darwin: "macOS", +}; + +export function EnhancementsView() { + const { t } = useI18n(); + const [snap, setSnap] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + api.enhancements + .snapshot() + .then(setSnap) + .catch((e) => setError(errorMessage(e, t("enhancements:loadError")))); + }, [t]); + + if (error) + return ( +
+ {error} +
+ ); + if (!snap) return ; + + const byId = new Map(snap.states.map((s) => [s.id, s])); + + return ( +
+
+

{t("enhancements:title")}

+

{t("enhancements:subtitle")}

+
+ +
+ {CATALOG.map((meta) => ( + + ))} +
+
+ ); +} + +function EnhancementCard({ + meta, + state, + platform, + onChange, +}: { + meta: Meta; + state: EnhancementState | undefined; + platform: OsPlatform; + onChange: (snap: EnhancementsSnapshot) => void; +}) { + const { t } = useI18n(); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const supported = meta.platforms.includes(platform); + const enabled = state?.enabled ?? false; + + async function toggle(next: boolean) { + setBusy(true); + setErr(null); + try { + onChange(await api.enhancements.setEnabled(meta.id, next)); + } catch (e) { + setErr(errorMessage(e, t("enhancements:genericError"))); + } finally { + setBusy(false); + } + } + + return ( +
+
+ {meta.icon} +
+
+

{t(`enhancements:items.${meta.id}.title`)}

+ {meta.platforms.map((p) => ( + + {OS_LABEL[p]} + + ))} +
+

+ {t(`enhancements:items.${meta.id}.blurb`)} +

+ {supported && state?.detail ? ( +

+ {t(`enhancements:items.${meta.id}.detail.${state.detail.key}`, { + path: state.detail.path, + })} +

+ ) : null} + {!supported ? ( +

+ {t("enhancements:onlyAvailable", { + platforms: meta.platforms.map((p) => OS_LABEL[p]).join(", "), + })} +

+ ) : null} + {err ? ( +

+ {err} +

+ ) : null} +
+ +
+
+ ); +} diff --git a/src/renderer/src/features/friends/FriendsSidebar.tsx b/src/renderer/src/features/friends/FriendsSidebar.tsx new file mode 100644 index 0000000..9df60dc --- /dev/null +++ b/src/renderer/src/features/friends/FriendsSidebar.tsx @@ -0,0 +1,103 @@ +import { useMemo } from "react"; +import { isOnline, locationLabel, statusMeta } from "../../lib/vrchat"; +import { Badge, PresenceAvatar } from "../../components/ui"; +import { useFriends, useSelf } from "../../store/social"; +import { useWorldName } from "../../store/worlds"; +import { parseLocation, type UserProfile } from "../../../../shared/types/user"; + +export function FriendsSidebar({ onOpen }: { onOpen: (id: string) => void }) { + const friends = useFriends(); + const self = useSelf(); + + const sorted = useMemo( + () => + [...friends].sort( + (a, b) => + Number(isOnline(b)) - Number(isOnline(a)) || a.displayName.localeCompare(b.displayName), + ), + [friends], + ); + + const online = sorted.filter(isOnline); + const offline = sorted.filter((f) => !isOnline(f)); + + return ( + + ); +} + +function FriendRow({ + friend, + onOpen, + dim, + isSelf, +}: { + friend: UserProfile; + onOpen: (id: string) => void; + dim?: boolean; + isSelf?: boolean; +}) { + const status = statusMeta[isOnline(friend) || isSelf ? friend.status : "offline"]; + const sub = friend.statusDescription || status.label; + const parsed = parseLocation(friend.location); + const worldName = useWorldName(parsed?.worldId); + const location = parsed + ? worldName + ? `in ${worldName}` + : "In a world" + : locationLabel(friend.location); + return ( + + ); +} diff --git a/src/renderer/src/features/gallery/GalleryView.tsx b/src/renderer/src/features/gallery/GalleryView.tsx new file mode 100644 index 0000000..7563a61 --- /dev/null +++ b/src/renderer/src/features/gallery/GalleryView.tsx @@ -0,0 +1,508 @@ +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Trans } from "react-i18next"; +import { Check, Globe, Images, RefreshCw, Search, Trash2, X } from "lucide-react"; +import type { Photo } from "../../../../shared/types/gallery"; +import { Banner, Loader } from "../../components/ui"; +import { useI18n } from "../../lib/i18n"; +import { useGallery } from "./useGallery"; +import { Lightbox } from "./Lightbox"; +import { Timeline } from "./Timeline"; +import { justify } from "./justify"; +import { formatBucket } from "./format"; +import "./gallery.css"; + +const ROW_HEIGHT = 200; +const GAP = 8; +const DRAG_THRESHOLD = 6; + +interface Section { + bucket: string; + items: Photo[]; +} + +function sameSet(a: Set, b: Set): boolean { + if (a.size !== b.size) return false; + for (const x of a) if (!b.has(x)) return false; + return true; +} + +export function GalleryView() { + const { t } = useI18n(); + const { snap, loading, error, reload, remove, recent } = useGallery(); + const [query, setQuery] = useState(""); + const [activeId, setActiveId] = useState(null); + const [selected, setSelected] = useState>(() => new Set()); + + const scrollRef = useRef(null); + const bandRef = useRef(null); + const [width, gridRef] = useWidth(); + + const photos = snap?.photos ?? []; + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return photos; + return photos.filter((p) => + [p.fileName, p.metadata.worldName, p.metadata.author].some((v) => + v?.toLowerCase().includes(q), + ), + ); + }, [photos, query]); + + const sections = useMemo(() => { + const map = new Map(); + for (const p of filtered) { + const list = map.get(p.bucket); + if (list) list.push(p); + else map.set(p.bucket, [p]); + } + return [...map.entries()].map(([bucket, items]) => ({ bucket, items })); + }, [filtered]); + + const activeIndex = activeId ? filtered.findIndex((p) => p.id === activeId) : -1; + + useEffect(() => { + if (activeId && !filtered.some((p) => p.id === activeId)) setActiveId(null); + }, [activeId, filtered]); + + 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 selecting = selected.size > 0; + const toggle = useCallback((id: string) => { + setSelected((s) => { + const n = new Set(s); + if (n.has(id)) n.delete(id); + else n.add(id); + return n; + }); + }, []); + const setMany = useCallback((ids: string[], on: boolean) => { + setSelected((s) => { + const n = new Set(s); + for (const id of ids) { + if (on) n.add(id); + else n.delete(id); + } + return n; + }); + }, []); + const clearSel = useCallback(() => setSelected((s) => (s.size ? new Set() : s)), []); + const open = useCallback((p: Photo) => setActiveId(p.id), []); + + const jumpTo = useCallback((bucket: string) => { + document + .getElementById(`bucket-${bucket}`) + ?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, []); + + const deleteIds = useCallback( + async (ids: string[]) => { + try { + await remove(ids); + } catch {} + }, + [remove], + ); + + const deleteSelected = useCallback(async () => { + const ids = [...selected]; + clearSel(); + await deleteIds(ids); + }, [selected, clearSel, deleteIds]); + + const deleteActive = useCallback(async () => { + if (activeIndex < 0) return; + const photo = filtered[activeIndex]; + if (!photo) return; + const neighbour = filtered[activeIndex + 1] ?? filtered[activeIndex - 1] ?? null; + setActiveId(neighbour && neighbour.id !== photo.id ? neighbour.id : null); + await deleteIds([photo.id]); + }, [activeIndex, filtered, deleteIds]); + + const selectedRef = useRef(selected); + selectedRef.current = selected; + const startRef = useRef<{ x: number; y: number } | null>(null); + const baseRef = useRef>(new Set()); + const rectsRef = useRef<{ id: string; r: DOMRect }[]>([]); + const lastPtRef = useRef<{ x: number; y: number } | null>(null); + const appliedRef = useRef>(new Set()); + const rafRef = useRef(null); + const draggingRef = useRef(false); + const suppressClickRef = useRef(false); + + const flushDrag = useCallback(() => { + rafRef.current = null; + const el = scrollRef.current; + const start = startRef.current; + const pt = lastPtRef.current; + if (!el || !start || !pt) return; + const left = Math.min(start.x, pt.x); + const top = Math.min(start.y, pt.y); + const right = Math.max(start.x, pt.x); + const bottom = Math.max(start.y, pt.y); + + const next = new Set(baseRef.current); + for (const { id, r } of rectsRef.current) { + if (r.left < right && r.right > left && r.top < bottom && r.bottom > top) next.add(id); + } + if (!sameSet(next, appliedRef.current)) { + appliedRef.current = next; + setSelected(next); + } + + const sr = el.getBoundingClientRect(); + const band = bandRef.current; + if (band) { + band.style.left = `${left - sr.left + el.scrollLeft}px`; + band.style.top = `${top - sr.top + el.scrollTop}px`; + band.style.width = `${right - left}px`; + band.style.height = `${bottom - top}px`; + band.hidden = false; + } + }, []); + + const onPointerDown = useCallback((e: React.PointerEvent) => { + if (e.button !== 0) return; + startRef.current = { x: e.clientX, y: e.clientY }; + baseRef.current = new Set(selectedRef.current); + appliedRef.current = selectedRef.current; + draggingRef.current = false; + }, []); + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + const start = startRef.current; + const el = scrollRef.current; + if (!start || !el) return; + if (!draggingRef.current) { + if (Math.hypot(e.clientX - start.x, e.clientY - start.y) < DRAG_THRESHOLD) return; + draggingRef.current = true; + el.setPointerCapture?.(e.pointerId); + el.classList.add("is-dragging"); + rectsRef.current = [...el.querySelectorAll("[data-pid]")].map((node) => ({ + id: node.dataset.pid!, + r: node.getBoundingClientRect(), + })); + } + lastPtRef.current = { x: e.clientX, y: e.clientY }; + if (rafRef.current === null) rafRef.current = requestAnimationFrame(flushDrag); + }, + [flushDrag], + ); + + const endDrag = useCallback(() => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + if (draggingRef.current) suppressClickRef.current = true; + draggingRef.current = false; + startRef.current = null; + scrollRef.current?.classList.remove("is-dragging"); + if (bandRef.current) bandRef.current.hidden = true; + }, []); + + const onClickCapture = useCallback((e: React.MouseEvent) => { + if (!suppressClickRef.current) return; + e.preventDefault(); + e.stopPropagation(); + suppressClickRef.current = false; + }, []); + + if (loading && !snap) return ; + + return ( +
+
+
+

{t("gallery:title")}

+

+ {photos.length > 0 + ? t("gallery:photoCount", { count: photos.length }) + : t("gallery:subtitle")} +

+
+
+ + +
+
+ + {error ? {error} : null} + + {!error && photos.length === 0 ? : null} + + {!error && photos.length > 0 && filtered.length === 0 ? ( +

{t("gallery:noMatch", { query })}

+ ) : null} + + {sections.length > 0 ? ( +
+
+
+ {sections.map((section) => ( + + ))} +
+ + +
+ ) : null} + + {selecting ? ( +
+ + {t("gallery:selectedCount", { count: selected.size })} + + + +
+ ) : null} + + {activeIndex >= 0 ? ( + setActiveId(null)} + onNavigate={(i) => setActiveId(filtered[i]?.id ?? null)} + onDelete={deleteActive} + /> + ) : null} +
+ ); +} + +function SectionBlock({ + section, + width, + selected, + selecting, + recent, + onToggle, + onToggleBucket, + onOpen, +}: { + section: Section; + width: number; + selected: Set; + selecting: boolean; + recent: ReadonlySet; + onToggle: (id: string) => void; + onToggleBucket: (ids: string[], on: boolean) => void; + onOpen: (p: Photo) => void; +}) { + const { t, locale } = useI18n(); + const rows = useMemo( + () => (width > 0 ? justify(section.items, width, ROW_HEIGHT, GAP) : []), + [section.items, width], + ); + + const ids = useMemo(() => section.items.map((p) => p.id), [section.items]); + const selCount = ids.reduce((n, id) => n + (selected.has(id) ? 1 : 0), 0); + const allSel = selCount > 0 && selCount === ids.length; + const someSel = selCount > 0 && !allSel; + const month = formatBucket(section.bucket, locale); + + return ( +
+

+ e.stopPropagation()} + onClick={() => onToggleBucket(ids, !allSel)} + > + + + {month} +

+
+ {rows.map((row, i) => ( +
+ {row.map((tile) => ( + + ))} +
+ ))} +
+
+ ); +} + +const PhotoTile = memo(function PhotoTile({ + photo, + width, + height, + selected, + selecting, + isNew, + onToggle, + onOpen, +}: { + photo: Photo; + width: number; + height: number; + selected: boolean; + selecting: boolean; + isNew: boolean; + onToggle: (id: string) => void; + onOpen: (p: Photo) => void; +}) { + const { t } = useI18n(); + const ref = useRef(null); + const visible = useInView(ref); + const [loaded, setLoaded] = useState(false); + return ( + + ); +}); + +function useInView(ref: React.RefObject): boolean { + const [seen, setSeen] = useState(false); + useEffect(() => { + if (seen || !ref.current) return; + const el = ref.current; + const io = new IntersectionObserver( + (entries) => { + if (entries.some((e) => e.isIntersecting)) { + setSeen(true); + io.disconnect(); + } + }, + { rootMargin: "400px" }, + ); + io.observe(el); + return () => io.disconnect(); + }, [ref, seen]); + return seen; +} + +function useWidth(): [number, (el: HTMLElement | null) => void] { + const [w, setW] = useState(0); + const ro = useRef(null); + const ref = useCallback((el: HTMLElement | null) => { + ro.current?.disconnect(); + if (!el) return; + ro.current = new ResizeObserver((entries) => setW(entries[0].contentRect.width)); + ro.current.observe(el); + setW(el.clientWidth); + }, []); + return [w, ref]; +} + +function EmptyState({ roots }: { roots: string[] }) { + const { t } = useI18n(); + return ( +
+ +

{t("gallery:empty.title")}

+

+ }} /> +

+ {roots.length > 0 ? ( +

+ {t("gallery:empty.scanning")}{" "} + {roots.map((r) => ( + {r} + ))} +

+ ) : null} +
+ ); +} diff --git a/src/renderer/src/features/gallery/Lightbox.tsx b/src/renderer/src/features/gallery/Lightbox.tsx new file mode 100644 index 0000000..7cc8a93 --- /dev/null +++ b/src/renderer/src/features/gallery/Lightbox.tsx @@ -0,0 +1,200 @@ +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; +import { + ChevronLeft, + ChevronRight, + ExternalLink, + FolderOpen, + Globe, + Trash2, + User, + X, +} from "lucide-react"; +import type { Photo } from "../../../../shared/types/gallery"; +import { api } from "../../lib/api"; +import { useI18n } from "../../lib/i18n"; +import { useNav } from "../navigation/NavContext"; +import { formatBytes } from "./format"; +import { formatDateTime } from "../../lib/format"; + +interface Props { + photos: Photo[]; + index: number; + onClose: () => void; + onNavigate: (index: number) => void; + onDelete: () => void | Promise; +} + +export function Lightbox({ photos, index, onClose, onNavigate, onDelete }: Props) { + const { t, locale } = useI18n(); + const nav = useNav(); + const photo = photos[index]; + + const prev = () => onNavigate((index - 1 + photos.length) % photos.length); + const next = () => onNavigate((index + 1) % photos.length); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + else if (e.key === "ArrowLeft") prev(); + else if (e.key === "ArrowRight") next(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }); + + useEffect(() => { + if (photos.length < 2) return; + const n = photos.length; + const seen = new Set([photos[index]?.id]); + const imgs: HTMLImageElement[] = []; + for (const o of [1, -1, 2, -2]) { + const p = photos[(((index + o) % n) + n) % n]; + if (!p || seen.has(p.id)) continue; + seen.add(p.id); + const img = new Image(); + img.src = p.src; + imgs.push(img); + } + return () => imgs.forEach((img) => (img.src = "")); + }, [index, photos]); + + if (!photo) return null; + const m = photo.metadata; + + return createPortal( +
+ + + {photos.length > 1 ? ( + <> + + + + ) : null} + +
+ + + +
+
, + document.body, + ); +} + +function LightboxImage({ photo }: { photo: Photo }) { + const [loaded, setLoaded] = useState(false); + const { width, height } = photo.metadata; + if (!width || !height) { + return ( +
+ {photo.fileName} +
+ ); + } + return ( +
+
+ + {photo.fileName} setLoaded(true)} + /> +
+
+ ); +} + +function Fact({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} diff --git a/src/renderer/src/features/gallery/Timeline.tsx b/src/renderer/src/features/gallery/Timeline.tsx new file mode 100644 index 0000000..f17dc29 --- /dev/null +++ b/src/renderer/src/features/gallery/Timeline.tsx @@ -0,0 +1,66 @@ +import { useEffect, useState } from "react"; +import type { Photo } from "../../../../shared/types/gallery"; + +interface Section { + bucket: string; + items: Photo[]; +} + +interface Props { + sections: Section[]; + scrollRef: React.RefObject; + onJump: (bucket: string) => void; +} + +export function Timeline({ sections, scrollRef, onJump }: Props) { + const [activeBucket, setActiveBucket] = useState(null); + + useEffect(() => { + const root = scrollRef.current; + if (!root) return; + const update = () => { + const mid = root.scrollTop + root.clientHeight * 0.25; + let current = sections[0]?.bucket ?? null; + for (const s of sections) { + const el = document.getElementById(`bucket-${s.bucket}`); + if (el && el.offsetTop <= mid) current = s.bucket; + } + setActiveBucket(current); + }; + update(); + root.addEventListener("scroll", update, { passive: true }); + return () => root.removeEventListener("scroll", update); + }, [sections, scrollRef]); + + return ( + + ); +} + +function formatMonth(bucket: string): string { + const m = /^(\d{4})-(\d{2})$/.exec(bucket); + if (!m) return bucket; + const d = new Date(Number(m[1]), Number(m[2]) - 1, 1); + return Number.isNaN(d.getTime()) + ? bucket + : d.toLocaleDateString(undefined, { year: "numeric", month: "long" }); +} diff --git a/src/renderer/src/features/gallery/format.ts b/src/renderer/src/features/gallery/format.ts new file mode 100644 index 0000000..91e4745 --- /dev/null +++ b/src/renderer/src/features/gallery/format.ts @@ -0,0 +1,19 @@ +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB"]; + let n = bytes / 1024; + let i = 0; + while (n >= 1024 && i < units.length - 1) { + n /= 1024; + i++; + } + return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`; +} + +export function formatBucket(bucket: string, locale?: string): string { + const m = /^(\d{4})-(\d{2})$/.exec(bucket); + if (!m) return bucket; + const d = new Date(Number(m[1]), Number(m[2]) - 1, 1); + if (Number.isNaN(d.getTime())) return bucket; + return d.toLocaleDateString(locale, { calendar: "gregory", year: "numeric", month: "long" }); +} diff --git a/src/renderer/src/features/gallery/gallery.css b/src/renderer/src/features/gallery/gallery.css new file mode 100644 index 0000000..7950b96 --- /dev/null +++ b/src/renderer/src/features/gallery/gallery.css @@ -0,0 +1,637 @@ +.gallery { + width: 100%; + max-width: 1200px; + margin: 0 auto; + padding: 40px 32px 0; + display: flex; + flex-direction: column; + gap: 18px; + height: 100%; + min-height: 0; +} + +.gallery__head { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.gallery__heading h1 { + font-size: 26px; + font-weight: 700; + letter-spacing: -0.4px; +} +.gallery__heading p { + margin-top: 2px; + font-size: 13.5px; + color: var(--muted); +} + +.gallery__tools { + display: flex; + align-items: center; + gap: 8px; +} +.gallery__search { + display: flex; + align-items: center; + gap: 8px; + padding: 0 12px; + height: 36px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--faint); + transition: border-color var(--dur) var(--ease); +} +.gallery__search:focus-within { + border-color: var(--accent); +} +.gallery__search input { + border: 0; + background: transparent; + outline: none; + color: var(--text); + font-size: 13px; + width: 220px; +} +.gallery__search input::placeholder { + color: var(--faint); +} + +.gallery__refresh { + display: grid; + place-items: center; + width: 36px; + height: 36px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--muted); + transition: + color var(--dur) var(--ease), + border-color var(--dur) var(--ease); +} +.gallery__refresh:hover:not(:disabled) { + color: var(--text); + border-color: var(--border-strong); +} +.gallery__refresh:disabled { + opacity: 0.6; +} + +.gallery__main { + flex: 1; + min-height: 0; + display: flex; + gap: 8px; +} +.gallery__scroll { + position: relative; + flex: 1; + min-width: 0; + overflow-y: auto; + scrollbar-gutter: stable; + padding-bottom: 56px; +} +.gallery__grid { + display: flex; + flex-direction: column; + gap: 28px; +} + +.gallery__section { + display: flex; + flex-direction: column; + gap: 12px; +} +.gallery__month { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + color: var(--muted); + letter-spacing: 0.2px; + position: sticky; + top: 0; + z-index: 2; + padding: 6px 0; + background: linear-gradient(var(--surface), var(--surface) 70%, transparent); +} + +.gallery__check, +.tile__check { + display: grid; + place-items: center; + width: 18px; + height: 18px; + border-radius: 6px; + border: 1.5px solid var(--border-strong); + background: color-mix(in srgb, var(--surface) 70%, transparent); + color: transparent; + cursor: pointer; + transition: + background var(--dur) var(--ease), + border-color var(--dur) var(--ease), + color var(--dur) var(--ease), + opacity var(--dur) var(--ease), + transform var(--dur) var(--ease); +} +.gallery__check { + flex: none; + opacity: 0; +} +.gallery__section:hover .gallery__check, +.gallery.is-selecting .gallery__check, +.gallery__check.is-on, +.gallery__check.is-mixed { + opacity: 1; +} +.gallery__check:hover, +.tile__check:hover { + border-color: var(--accent); +} +.gallery__check.is-on, +.tile__check.is-on { + background: var(--accent); + border-color: var(--accent); + color: var(--on-accent); +} +.gallery__check.is-mixed { + background: color-mix(in srgb, var(--accent) 35%, transparent); + border-color: var(--accent); +} + +.gallery__rows { + display: flex; + flex-direction: column; + gap: 8px; +} +.gallery__row { + display: flex; +} + +.timeline { + flex: none; + width: 56px; + padding: 4px 0; + overflow: hidden; +} +.timeline__rail { + height: 100%; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + padding-right: 6px; +} +.timeline__tick { + position: relative; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 7px; + width: 100%; + min-height: 12px; + flex: 1 1 0; + border: 0; + background: transparent; + cursor: pointer; + color: var(--faint); +} +.timeline__year { + font-size: 10.5px; + font-weight: 600; + font-variant-numeric: tabular-nums; + letter-spacing: 0.3px; + color: var(--muted); + transition: color var(--dur) var(--ease); +} +.timeline__dot { + flex: none; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--border-strong); + transition: + transform var(--dur) var(--ease), + background var(--dur) var(--ease); +} +.timeline__tick:hover .timeline__dot { + background: var(--muted); + transform: scale(1.4); +} +.timeline__tick:hover .timeline__year { + color: var(--text); +} +.timeline__tick.is-active .timeline__dot { + background: var(--accent); + transform: scale(1.7); +} +.timeline__tick.is-active .timeline__year { + color: var(--accent); +} + +.tile { + position: relative; + display: block; + flex: none; + border-radius: var(--radius); + overflow: hidden; + background: var(--surface-2); + border: 1px solid var(--border); + cursor: pointer; + transition: + transform var(--dur) var(--ease), + box-shadow var(--dur) var(--ease); +} +.tile:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-2); +} +.tile img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + opacity: 0; + transition: opacity var(--dur) var(--ease); +} +.tile img.is-loaded { + opacity: 1; +} +.tile__world { + position: absolute; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + gap: 5px; + padding: 14px 10px 7px; + font-size: 11px; + color: var(--on-overlay); + background: linear-gradient(transparent, var(--overlay-strong)); + opacity: 0; + transition: opacity var(--dur) var(--ease); +} +.tile:hover .tile__world { + opacity: 1; +} + +.tile.is-new { + animation: pop-in var(--dur-lg) var(--ease-out) both; +} + +.tile.is-selected { + outline: 3px solid var(--accent); + outline-offset: -3px; +} +.tile.is-selected img { + transform: scale(0.9); + transition: + transform var(--dur) var(--ease), + opacity var(--dur) var(--ease); +} +.tile__check { + position: absolute; + top: 8px; + left: 8px; + z-index: 1; + opacity: 0; +} +.tile:hover .tile__check, +.gallery.is-selecting .tile__check, +.tile__check.is-on { + opacity: 1; +} + +.gallery__scroll.is-dragging { + user-select: none; +} +.gallery__scroll.is-dragging .tile { + cursor: default; +} +.gallery__band { + position: absolute; + z-index: 3; + pointer-events: none; + border: 1px solid var(--accent); + background: color-mix(in srgb, var(--accent) 16%, transparent); + border-radius: 3px; +} + +.gallery__selbar { + position: absolute; + left: 50%; + bottom: 20px; + transform: translateX(-50%); + z-index: 5; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px 8px 16px; + border-radius: 999px; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--surface) 86%, transparent); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + box-shadow: var(--shadow-2); + animation: pop-in var(--dur) var(--ease-out) both; +} +.gallery__selcount { + font-size: 13px; + font-weight: 600; + color: var(--text); + margin-right: 4px; +} +.gallery__selbtn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 14px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + font-size: 12.5px; + font-weight: 600; + transition: + border-color var(--dur) var(--ease), + background var(--dur) var(--ease), + color var(--dur) var(--ease); +} +.gallery__selbtn:hover { + border-color: var(--border-strong); + background: var(--surface-hover); +} +.gallery__selbtn.is-danger:hover { + border-color: var(--danger); + background: color-mix(in srgb, var(--danger) 14%, transparent); + color: var(--danger); +} + +.gallery__none { + font-size: 13px; + color: var(--muted); +} + +.gallery__empty { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 8px; + padding: 72px 20px; + color: var(--muted); +} +.gallery__empty h2 { + font-size: 17px; + font-weight: 600; + color: var(--text); +} +.gallery__empty p { + font-size: 13px; + max-width: 380px; +} +.gallery__empty code, +.gallery__roots code { + font-family: var(--font-mono, monospace); + font-size: 12px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 1px 5px; +} +.gallery__roots { + display: flex; + flex-wrap: wrap; + gap: 6px; + justify-content: center; +} + +.lightbox { + position: fixed; + inset: 0; + z-index: 60; + display: grid; + place-items: center; + padding: 28px; + animation: fade-in var(--dur) var(--ease-out) both; +} +.lightbox__scrim { + position: absolute; + inset: 0; + border: 0; + background: color-mix(in srgb, var(--surface) 12%, #000 80%); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); +} +.lightbox__close { + position: absolute; + top: 16px; + right: 18px; + z-index: 2; + display: grid; + place-items: center; + width: 38px; + height: 38px; + border-radius: 50%; + border: 0; + color: var(--on-overlay); + background: color-mix(in srgb, var(--on-overlay) 10%, transparent); + transition: background var(--dur) var(--ease); +} +.lightbox__close:hover { + background: color-mix(in srgb, var(--on-overlay) 20%, transparent); +} +.lightbox__arrow { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 2; + display: grid; + place-items: center; + width: 44px; + height: 44px; + border-radius: 50%; + border: 0; + color: var(--on-overlay); + background: color-mix(in srgb, var(--on-overlay) 8%, transparent); + transition: background var(--dur) var(--ease); +} +.lightbox__arrow:hover { + background: color-mix(in srgb, var(--on-overlay) 20%, transparent); +} +.lightbox__arrow--left { + left: 18px; +} +.lightbox__arrow--right { + right: 18px; +} + +.lightbox__body { + position: relative; + z-index: 1; + display: flex; + gap: 18px; + max-width: 1280px; + max-height: 100%; + width: 100%; + align-items: stretch; + animation: pop-in var(--dur) var(--ease-out) both; +} +.lightbox__stage { + flex: 1; + min-width: 0; + display: grid; + place-items: center; +} +.lightbox__img { + max-width: 100%; + max-height: calc(100vh - 56px); + object-fit: contain; + border-radius: var(--radius); + box-shadow: var(--shadow-strong); +} + +.lightbox__frame { + position: relative; + height: calc(100vh - 56px); + width: auto; + max-width: 100%; + max-height: calc(100vh - 56px); + border-radius: var(--radius); + overflow: hidden; + box-shadow: var(--shadow-strong); +} +.lightbox__layer { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} +.lightbox__layer--full { + opacity: 0; + transition: opacity var(--dur) var(--ease); +} +.lightbox__layer--full.is-loaded { + opacity: 1; +} + +.lightbox__meta { + flex: none; + width: 280px; + align-self: center; + max-height: calc(100vh - 56px); + overflow: auto; + padding: 18px; + border-radius: var(--radius-lg); + border: 1px solid var(--border); + background: var(--surface); + box-shadow: var(--shadow-2); +} +.lightbox__title { + font-size: 13px; + font-weight: 600; + word-break: break-all; + margin-bottom: 14px; +} +.lightbox__facts { + display: flex; + flex-direction: column; + gap: 11px; +} +.lightbox__fact dt { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--faint); + margin-bottom: 2px; +} +.lightbox__fact dd { + font-size: 13px; + color: var(--text); +} +.lightbox__worldlink { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 100%; + border: 0; + background: transparent; + padding: 0; + color: var(--accent); + font-size: 13px; + cursor: pointer; +} +.lightbox__worldlink:disabled { + color: var(--text); + cursor: default; +} +.lightbox__worldlink:not(:disabled):hover { + text-decoration: underline; +} + +.lightbox__actions { + display: flex; + gap: 8px; + margin-top: 18px; +} +.lightbox__actions button { + flex: 1; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 34px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--surface-2); + color: var(--text); + font-size: 12.5px; + transition: + border-color var(--dur) var(--ease), + background var(--dur) var(--ease); +} +.lightbox__actions button:hover { + border-color: var(--border-strong); + background: var(--surface-hover); +} +.lightbox__actions button.is-danger { + flex: none; + padding: 0 12px; +} +.lightbox__actions button.is-danger:hover { + border-color: var(--danger); + background: color-mix(in srgb, var(--danger) 14%, transparent); + color: var(--danger); +} +.lightbox__counter { + margin-top: 14px; + text-align: center; + font-size: 11.5px; + color: var(--faint); +} + +@media (max-width: 760px) { + .lightbox__meta { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .lightbox, + .lightbox__body, + .lightbox__layer--full, + .gallery__selbar, + .tile { + animation: none; + transition: none; + } +} diff --git a/src/renderer/src/features/gallery/justify.ts b/src/renderer/src/features/gallery/justify.ts new file mode 100644 index 0000000..7524910 --- /dev/null +++ b/src/renderer/src/features/gallery/justify.ts @@ -0,0 +1,47 @@ +import type { Photo } from "../../../../shared/types/gallery"; + +export interface JustifiedTile { + photo: Photo; + width: number; + height: number; +} +export type JustifiedRow = JustifiedTile[]; + +export function justify( + photos: Photo[], + containerWidth: number, + targetHeight: number, + gap: number, +): JustifiedRow[] { + if (containerWidth <= 0) return []; + const rows: JustifiedRow[] = []; + let row: { photo: Photo; ratio: number }[] = []; + let ratioSum = 0; + + const flush = (last: boolean) => { + if (row.length === 0) return; + const gaps = gap * (row.length - 1); + let h = (containerWidth - gaps) / ratioSum; + if (last && h > targetHeight * 1.5) h = targetHeight; + rows.push(row.map(({ photo, ratio }) => ({ photo, width: ratio * h, height: h }))); + row = []; + ratioSum = 0; + }; + + for (const photo of photos) { + const ratio = aspect(photo); + row.push({ photo, ratio }); + ratioSum += ratio; + const gaps = gap * (row.length - 1); + const projected = (containerWidth - gaps) / ratioSum; + if (projected <= targetHeight) flush(false); + } + flush(true); + return rows; +} + +function aspect(photo: Photo): number { + const { width, height } = photo.metadata; + if (width && height) return width / height; + return 16 / 9; +} diff --git a/src/renderer/src/features/gallery/useGallery.ts b/src/renderer/src/features/gallery/useGallery.ts new file mode 100644 index 0000000..eee32e1 --- /dev/null +++ b/src/renderer/src/features/gallery/useGallery.ts @@ -0,0 +1,69 @@ +import { useCallback, useEffect, useState } from "react"; +import { api, errorMessage, events } from "../../lib/api"; +import type { GallerySnapshot, Photo } from "../../../../shared/types/gallery"; + +interface GalleryData { + snap: GallerySnapshot | null; + loading: boolean; + error: string | null; + reload: () => void; + remove: (ids: string[]) => Promise; + recent: ReadonlySet; +} + +const sortKey = (p: Photo): string => p.metadata.takenAt ?? p.modifiedAt; + +export function useGallery(): GalleryData { + const [snap, setSnap] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [recent, setRecent] = useState>(() => new Set()); + + const reload = useCallback(() => { + setLoading(true); + setError(null); + api.gallery + .snapshot() + .then(setSnap) + .catch((e) => setError(errorMessage(e, "Could not load your gallery."))) + .finally(() => setLoading(false)); + }, []); + + const remove = useCallback( + async (ids: string[]) => { + if (ids.length === 0) return; + const drop = new Set(ids); + try { + await api.gallery.delete(ids); + setSnap((s) => (s ? { ...s, photos: s.photos.filter((p) => !drop.has(p.id)) } : s)); + } catch (e) { + reload(); + throw e; + } + }, + [reload], + ); + + useEffect(reload, [reload]); + + useEffect(() => { + return events.on("gallery:added", (photo) => { + setSnap((s) => { + if (!s || s.photos.some((p) => p.id === photo.id)) return s; + const photos = [...s.photos, photo].sort((a, b) => sortKey(b).localeCompare(sortKey(a))); + return { ...s, empty: false, photos }; + }); + setRecent((r) => new Set(r).add(photo.id)); + window.setTimeout(() => { + setRecent((r) => { + if (!r.has(photo.id)) return r; + const n = new Set(r); + n.delete(photo.id); + return n; + }); + }, 1400); + }); + }, []); + + return { snap, loading, error, reload, remove, recent }; +} diff --git a/src/renderer/src/features/game/LaunchButton.tsx b/src/renderer/src/features/game/LaunchButton.tsx new file mode 100644 index 0000000..5408aba --- /dev/null +++ b/src/renderer/src/features/game/LaunchButton.tsx @@ -0,0 +1,52 @@ +import { useEffect, useState } from "react"; +import { ChevronRight } from "lucide-react"; +import { Button } from "../../components/ui/Button"; +import { api, events } from "../../lib/api"; +import { useI18n } from "../../lib/i18n"; + +export function LaunchButton() { + const { t } = useI18n(); + const [running, setRunning] = useState(false); + const [supported, setSupported] = useState(true); + const [launching, setLaunching] = useState(false); + + useEffect(() => { + void api.game.status().then((s) => { + setRunning(s.running); + setSupported(s.supported); + }); + return events.on("game:changed", (s) => setRunning(s.running)); + }, []); + + useEffect(() => { + if (running) setLaunching(false); + }, [running]); + + const onClick = async () => { + if (launching) return; + if (!running) setLaunching(true); + try { + const s = await api.game.launch(); + setRunning(s.running); + } catch { + setLaunching(false); + } + }; + + if (!supported) { + return ( + + ); + } + + const label = running ? t("game:running") : launching ? t("game:launching") : t("game:launch"); + + return ( + + ); +} diff --git a/src/renderer/src/features/navigation/NavContext.tsx b/src/renderer/src/features/navigation/NavContext.tsx new file mode 100644 index 0000000..c71ac44 --- /dev/null +++ b/src/renderer/src/features/navigation/NavContext.tsx @@ -0,0 +1,69 @@ +import { createContext, useContext, useMemo, useState } from "react"; + +export type View = + | { kind: "user"; id: "me" | string } + | { kind: "world"; id: string } + | { kind: "account" } + | { kind: "settings" } + | { kind: "enhancements" } + | { kind: "gallery" } + | { kind: "search" }; + +interface Nav { + current: View; + canBack: boolean; + openUser: (id: "me" | string) => void; + openWorld: (id: string) => void; + openAccount: () => void; + openSettings: () => void; + openEnhancements: () => void; + openGallery: () => void; + openSearch: () => void; + back: () => void; +} + +const NavCtx = createContext