From 8c199e8c95b5c87247e4f63d64083514e9859217 Mon Sep 17 00:00:00 2001 From: akarachen Date: Fri, 6 Feb 2026 21:26:14 +0800 Subject: [PATCH 1/6] init: add interface --- docs/architecture-database-migration.md | 443 ++++++++++++++++++++++++ src/data/hooks.ts | 305 ++++++++++++++++ src/data/index.ts | 60 ++++ src/data/interfaces.ts | 155 +++++++++ src/data/zustand.ts | 254 ++++++++++++++ 5 files changed, 1217 insertions(+) create mode 100644 docs/architecture-database-migration.md create mode 100644 src/data/hooks.ts create mode 100644 src/data/index.ts create mode 100644 src/data/interfaces.ts create mode 100644 src/data/zustand.ts diff --git a/docs/architecture-database-migration.md b/docs/architecture-database-migration.md new file mode 100644 index 0000000..1b794a5 --- /dev/null +++ b/docs/architecture-database-migration.md @@ -0,0 +1,443 @@ +# 状态管理架构:从 Zustand 迁移到后端数据库 + +## 1. 现状架构 + +### 整体数据流 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Renderer Process (React) │ +│ │ +│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │ +│ │ Zustand │ │ React Query │ │ DownloadBridge│ │ +│ │ Stores │ │ (tRPC hooks) │ │ (IPC listener)│ │ +│ │ + localStorage│ │ │ │ │ │ +│ └──────┬───────┘ └──────┬───────┘ └───────┬───────┘ │ +│ │ │ │ │ +│─────────┼─────────────────┼───────────────────┼──────────│ +│ │ electron-trpc (IPC) │ │ +│─────────┼─────────────────┼───────────────────┼──────────│ +│ ▼ ▼ ▼ │ +│ Main Process (Node.js) │ +│ │ +│ ┌──────────────┐ ┌───────────────┐ ┌──────────────┐ │ +│ │ tRPC Router │ │ DownloadMgr │ │ SQLite │ │ +│ │ (8 routers) │ │ (Queue+Events)│ │ (TS Catalog) │ │ +│ └──────────────┘ └───────────────┘ └──────────────┘ │ +│ │ │ │ │ +│─────────┼─────────────────┼───────────────────┼──────────│ +│ ▼ ▼ ▼ │ +│ Filesystem / Thunderstore API / Game Processes │ +└─────────────────────────────────────────────────────────┘ +``` + +### 当前 6 个 Zustand Store 的职责 + +| Store | 持久化 | 位置 | 职责 | +|-------|--------|------|------| +| `useAppStore` | 否 | 内存 | UI 导航状态:当前视图、选中项、搜索、排序 | +| `useDownloadStore` | 否 | 内存 | 下载任务运行时状态,由 DownloadBridge 从主进程同步 | +| `useGameManagementStore` | 是 | `r2modman.gameManagement` | 用户管理的游戏列表、最近使用、默认游戏 | +| `useSettingsStore` | 是 | `r2modman.settings` | 全局设置 + 每游戏设置(路径、下载、UI 偏好) | +| `useProfileStore` | 是 | `r2modman.profiles` | 每游戏的 Profile 列表及激活状态 | +| `useModManagementStore` | 是 | `mod-management-storage.v2` | 每 Profile 的 mod 安装/启用/版本/依赖警告 | + +### 当前的问题 + +1. **数据分散** —— 用户数据分布在 4 个 localStorage key 中,跨 store 引用靠 string ID 手动关联,没有引用完整性 +2. **渲染进程持有权威数据** —— 持久化数据在 renderer 的 localStorage 中,main process 需要通过 tRPC 反向查询才能获取 +3. **Settings 同步** —— DownloadBridge 需要手动把 settings 变更推送到 main process 的内存缓存(`settings-state.ts`) +4. **无法跨窗口共享** —— 如果未来需要多窗口,localStorage 的 store 不会自动同步 + +--- + +## 2. 目标架构 + +将 4 个持久化 store 迁移到 main process 的 SQLite 数据库。Renderer 通过 tRPC 读写,不再直接持有权威状态。 + +``` +┌───────────────────────────────────────────────────────────┐ +│ Renderer Process │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │ +│ │ useAppStore │ │ useDownload │ │ React Query │ │ +│ │ (UI 瞬态) │ │ Store (瞬态) │ │ (tRPC hooks) │ │ +│ │ Zustand │ │ Zustand │ │ 缓存 + 失效 │ │ +│ └──────────────┘ └──────────────┘ └───────┬────────┘ │ +│ │ │ +│───────────────────────────────────────────────┼───────────│ +│ electron-trpc (IPC) │ │ +│───────────────────────────────────────────────┼───────────│ +│ ▼ │ +│ Main Process │ +│ │ +│ ┌──────────────┐ ┌───────────────┐ ┌──────────────┐ │ +│ │ tRPC Router │ │ DownloadMgr │ │ SQLite DB │ │ +│ │ │─>│ │─>│ │ │ +│ │ games.* │ │ │ │ Game │ │ +│ │ settings.* │ │ │ │ GameSettings │ │ +│ │ profiles.* │ │ │ │ Profile │ │ +│ │ mods.* │ │ │ │ ProfileMod │ │ +│ └──────────────┘ └───────────────┘ │ GlobalSettings│ │ +│ │ Mod (cache) │ │ +│ └──────────────┘ │ +└───────────────────────────────────────────────────────────┘ +``` + +**核心变化:** +- 权威数据源从 renderer localStorage 移到 main process SQLite +- Renderer 通过 React Query + tRPC 查询,自动缓存和失效 +- Main process 的 DownloadManager、PathResolver 等可以直接读 DB,不需要 settings 同步桥 +- `useAppStore` 和 `useDownloadStore` 保留在前端 Zustand(纯 UI/运行时状态) + +--- + +## 3. 数据库实体设计 + +### ER 关系图 + +``` +┌──────────────────┐ +│ GlobalSettings │ (单例,一行) +└──────────────────┘ + +┌────────┐ 1 0..1 ┌──────────────┐ +│ Game │────────>│ GameSettings │ +└────────┘ └──────────────┘ + │ 1 + │ N +┌─────────┐ 1 N ┌─────────────┐ N 1 ┌─────┐ N 1 ┌────────┐ +│ Profile │──────>│ ProfileMod │──────>│ Mod │──────>│ Game │ +└─────────┘ └─────────────┘ └─────┘ └────────┘ +``` + +### 3.1 GlobalSettings(单例) + +应用级全局配置。始终存在一行。 + +```sql +CREATE TABLE global_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), -- 强制单例 + + -- 路径 + data_folder TEXT NOT NULL DEFAULT '', + steam_folder TEXT NOT NULL DEFAULT '', + mod_download_folder TEXT NOT NULL DEFAULT '', + cache_folder TEXT NOT NULL DEFAULT '', + + -- 下载 + speed_limit_enabled INTEGER NOT NULL DEFAULT 0, -- boolean + speed_limit_bps INTEGER NOT NULL DEFAULT 0, + speed_unit TEXT NOT NULL DEFAULT 'Bps', + max_concurrent_downloads INTEGER NOT NULL DEFAULT 3, + download_cache_enabled INTEGER NOT NULL DEFAULT 1, + preferred_thunderstore_cdn TEXT NOT NULL DEFAULT 'main', + auto_install_mods INTEGER NOT NULL DEFAULT 1, + + -- Mod + enforce_dependency_versions INTEGER NOT NULL DEFAULT 1, + + -- UI + card_display_type TEXT NOT NULL DEFAULT 'collapsed', + theme TEXT NOT NULL DEFAULT 'dark', + language TEXT NOT NULL DEFAULT 'en', + funky_mode INTEGER NOT NULL DEFAULT 0 +); +``` + +### 3.2 Game + +用户管理的游戏。表中有记录 = 已管理,无需额外的 `managedGameIds` 数组。 + +```sql +CREATE TABLE game ( + id TEXT PRIMARY KEY, -- Thunderstore community identifier + is_default INTEGER NOT NULL DEFAULT 0, -- 全局唯一 true(应用层保证) + last_accessed_at TEXT, -- ISO8601,替代 recentManagedGameIds + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +**原 store 字段映射:** +- `managedGameIds` → `SELECT id FROM game` +- `recentManagedGameIds` → `SELECT id FROM game ORDER BY last_accessed_at DESC LIMIT 10` +- `defaultGameId` → `SELECT id FROM game WHERE is_default = 1` + +### 3.3 GameSettings + +每游戏的自定义设置。**独立于 Game 表**,便于重置(DELETE 整行即恢复默认)和与 GlobalSettings 做层级合并。 + +```sql +CREATE TABLE game_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + game_id TEXT NOT NULL UNIQUE REFERENCES game(id) ON DELETE CASCADE, + + -- 可覆盖全局的字段(nullable = 继承 GlobalSettings) + mod_download_folder TEXT, -- NULL → 继承 global + cache_folder TEXT, -- NULL → 继承 global + + -- 仅 per-game 的字段 + install_folder TEXT NOT NULL DEFAULT '', + mod_cache_folder TEXT NOT NULL DEFAULT '', + launch_parameters TEXT NOT NULL DEFAULT '', + online_mod_list_cache_date TEXT -- ISO8601 or NULL +); +``` + +**三层 Fallback 逻辑:** + +``` +最终值 = per-game 字段 ?? global 字段 ?? 硬编码默认值 +``` + +```typescript +function getEffectiveSettings(gameId: string) { + const defaults = DEFAULT_GAME_SETTINGS + const global = db.select().from(globalSettings).where(eq(id, 1)).get() + const perGame = db.select().from(gameSettings).where(eq(gameId, gameId)).get() + + return { + modDownloadFolder: perGame?.modDownloadFolder ?? global.modDownloadFolder ?? defaults.modDownloadFolder, + cacheFolder: perGame?.cacheFolder ?? global.cacheFolder ?? defaults.cacheFolder, + installFolder: perGame?.installFolder ?? defaults.installFolder, + // ... + } +} +``` + +**重置操作:** + +| 操作 | SQL | +|------|-----| +| 重置单个字段 | `UPDATE game_settings SET cache_folder = NULL WHERE game_id = ?` | +| 重置所有设置 | `DELETE FROM game_settings WHERE game_id = ?` | +| 删除游戏时自动清理 | `ON DELETE CASCADE` 自动处理 | + +### 3.4 Profile + +每个游戏下的 mod 配置方案。 + +```sql +CREATE TABLE profile ( + id TEXT PRIMARY KEY, -- '{gameId}-{uuid}' 或 '{gameId}-default' + game_id TEXT NOT NULL REFERENCES game(id) ON DELETE CASCADE, + name TEXT NOT NULL, + is_default INTEGER NOT NULL DEFAULT 0, -- 每游戏唯一 true + is_active INTEGER NOT NULL DEFAULT 0, -- 每游戏唯一 true + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_profile_game ON profile(game_id); +``` + +### 3.5 ProfileMod(关联表) + +Profile 中安装的 mod。合并了原来的 4 个 map:`installedModsByProfile`、`enabledModsByProfile`、`installedModVersionsByProfile`、`dependencyWarningsByProfile`。 + +```sql +CREATE TABLE profile_mod ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + profile_id TEXT NOT NULL REFERENCES profile(id) ON DELETE CASCADE, + mod_id TEXT NOT NULL, -- Thunderstore full_name,如 'Author-ModName' + installed_version TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + dependency_warnings TEXT, -- JSON array,如 '["missing-dep-1","missing-dep-2"]' + + UNIQUE(profile_id, mod_id) +); + +CREATE INDEX idx_profile_mod_profile ON profile_mod(profile_id); +CREATE INDEX idx_profile_mod_mod ON profile_mod(mod_id); +``` + +### 3.6 Mod(Thunderstore 缓存) + +Thunderstore API 的本地缓存。非用户数据,可随时重建。 + +> 注:目前已有独立的 SQLite catalog(`thunderstore-catalog/*.db`)。此表是可选的,取决于是否要统一到同一个 DB。 + +```sql +CREATE TABLE mod ( + id TEXT PRIMARY KEY, -- Thunderstore full_name + game_id TEXT NOT NULL REFERENCES game(id) ON DELETE CASCADE, + name TEXT NOT NULL, + author TEXT NOT NULL, + icon_url TEXT, + latest_version TEXT, + description TEXT, + cached_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_mod_game ON mod(game_id); +``` + +--- + +## 4. 不入库的部分 + +| 原 Store | 迁移后位置 | 原因 | +|----------|-----------|------| +| `useAppStore` | 保留 Zustand(前端内存) | 纯 UI 瞬态状态:当前视图、选中项、搜索词、排序方向 | +| `useDownloadStore` | 保留 Zustand(前端内存) | 下载任务是运行时生命周期,进程退出即清空 | + +--- + +## 5. 迁移后的数据流 + +### 5.1 读取流程(以"获取当前游戏的 Profile 列表"为例) + +``` +React Component + │ + │ const { data } = trpc.profiles.list.useQuery({ gameId }) + │ + ▼ +React Query ──── 缓存命中? ──── 是 → 直接返回缓存数据 + │ (staleTime 内不重新请求) + │ 否 + ▼ +electron-trpc (IPC) + │ + ▼ +Main Process: profilesRouter.list + │ + │ db.select().from(profile).where(eq(gameId, input.gameId)) + │ + ▼ +SQLite DB → 返回 Profile[] + │ + ▼ +React Query 缓存 → Component 渲染 +``` + +### 5.2 写入流程(以"安装 mod"为例) + +``` +React Component + │ + │ trpc.mods.install.useMutation() + │ + ▼ +Main Process: modsRouter.install + │ + │ 1. 文件操作:拷贝 mod 文件到 profile 目录 + │ 2. db.insert(profileMod).values({ profileId, modId, version, enabled: true }) + │ 3. 返回成功 + │ + ▼ +React Query 自动失效 + │ + │ onSuccess: () => { + │ queryClient.invalidateQueries(['profiles', gameId, 'mods']) + │ } + │ + ▼ +UI 自动刷新已安装 mod 列表 +``` + +### 5.3 Settings 读取流程(三层合并) + +``` +React Component + │ + │ trpc.settings.getEffective.useQuery({ gameId }) + │ + ▼ +Main Process: settingsRouter.getEffective + │ + │ const defaults = DEFAULT_GAME_SETTINGS + │ const global = db.select(globalSettings).get() + │ const perGame = db.select(gameSettings).where(gameId).get() + │ + │ return { ...defaults, ...global, ...perGame } // NULL 字段自动跳过 + │ + ▼ +React Query 缓存 → Component 使用合并后的 effective settings +``` + +### 5.4 Settings 重置流程 + +``` +React Component + │ + │ trpc.settings.resetPerGame.useMutation() + │ + ▼ +Main Process: settingsRouter.resetPerGame + │ + │ db.delete(gameSettings).where(eq(gameId, input.gameId)) + │ // 行被删除,下次读取自动 fallback 到 GlobalSettings + │ + ▼ +React Query 失效 → 重新 fetch → 返回的是 global 层的值 +``` + +### 5.5 下载流程(混合模式:DB + Zustand) + +下载任务仍是运行时状态,但 main process 可以直接从 DB 读取路径设置,不再需要 settings 同步桥。 + +``` +用户点击安装 + │ + ▼ +Renderer: trpc.downloads.enqueue.mutate({ gameId, modId, version }) + │ + ▼ +Main Process: + │ 1. 从 DB 读取 effective settings(路径、CDN 偏好、速度限制) + │ ── 不再需要从 renderer 同步 settings ── + │ 2. 创建 DownloadJob,加入 Queue + │ 3. Queue 按并发数调度 + │ + ▼ +DownloadQueue 事件 → IPC broadcast → DownloadBridge → useDownloadStore 更新 + │ + ▼ +下载完成 + autoInstall 开启? + │ 是 → trpc.mods.install.mutate() + │ → 文件拷贝 + DB 写入 profile_mod + │ → React Query 失效 → UI 刷新 +``` + +--- + +## 6. 迁移策略 + +### 第一阶段:建库 + 双写 + +1. 在 main process 中初始化 SQLite(`better-sqlite3`,复用现有依赖) +2. 应用启动时检测 localStorage 数据,自动迁移到 DB +3. 新的 tRPC procedure 读写 DB +4. 旧的 Zustand store 暂时保留,逐步替换调用方 + +### 第二阶段:切换读取源 + +1. 将 React 组件中的 `useXxxStore()` 调用替换为 `trpc.xxx.useQuery()` +2. 写操作改为 `trpc.xxx.useMutation()` + `invalidateQueries` +3. Main process 内部直接读 DB(DownloadManager、PathResolver 等) + +### 第三阶段:清理 + +1. 删除 4 个持久化 Zustand store(GameManagement、Settings、Profile、ModManagement) +2. 删除 DownloadBridge 中的 settings 同步逻辑 +3. 删除 `settings-state.ts`(main process 的 settings 内存缓存) +4. 清理 localStorage 迁移代码(或保留一个版本做兼容) + +--- + +## 7. 附:完整实体字段对照表 + +| 原 Store | 原字段 | 目标实体 | 目标字段 | +|----------|--------|---------|---------| +| GameManagement | `managedGameIds` | `game` | 表中所有行 | +| GameManagement | `recentManagedGameIds` | `game` | `last_accessed_at` 排序 | +| GameManagement | `defaultGameId` | `game` | `is_default` | +| Settings | `global.*` | `global_settings` | 对应字段 | +| Settings | `perGame[gameId].*` | `game_settings` | 对应字段(共有字段 nullable) | +| Profile | `profilesByGame` | `profile` | `game_id` 外键关联 | +| Profile | `activeProfileIdByGame` | `profile` | `is_active` | +| ModManagement | `installedModsByProfile` | `profile_mod` | 行的存在 = 已安装 | +| ModManagement | `enabledModsByProfile` | `profile_mod` | `enabled` | +| ModManagement | `installedModVersionsByProfile` | `profile_mod` | `installed_version` | +| ModManagement | `dependencyWarningsByProfile` | `profile_mod` | `dependency_warnings` (JSON) | +| ModManagement | `uninstallingMods` | 不入库 | 保留前端 Zustand | diff --git a/src/data/hooks.ts b/src/data/hooks.ts new file mode 100644 index 0000000..3234662 --- /dev/null +++ b/src/data/hooks.ts @@ -0,0 +1,305 @@ +/** + * React hooks – the stable API that components import. + * + * Reads: currently backed by Zustand selectors for zero-latency reactivity. + * Writes: always go through the async service interface. + * + * When we migrate to DB, reads switch to React Query (useQuery) and writes + * switch to useMutation. Component call-sites stay the same. + */ + +import { useMemo, useCallback } from "react" +import { useGameManagementStore } from "@/store/game-management-store" +import { useSettingsStore } from "@/store/settings-store" +import { useProfileStore } from "@/store/profile-store" +import { useModManagementStore } from "@/store/mod-management-store" +import { gameService, settingsService, profileService, modService } from "./index" +import type { + ManagedGame, + InstalledMod, + GlobalSettings, + GameSettings, + EffectiveGameSettings, + Profile, +} from "./interfaces" + +// --------------------------------------------------------------------------- +// Game hooks +// --------------------------------------------------------------------------- + +export function useGames(): { data: ManagedGame[] } { + const managedGameIds = useGameManagementStore((s) => s.managedGameIds) + const defaultGameId = useGameManagementStore((s) => s.defaultGameId) + + const data = useMemo( + () => + managedGameIds.map( + (id): ManagedGame => ({ + id, + isDefault: id === defaultGameId, + lastAccessedAt: null, + }), + ), + [managedGameIds, defaultGameId], + ) + + return { data } +} + +export function useDefaultGame(): { data: ManagedGame | null } { + const defaultGameId = useGameManagementStore((s) => s.defaultGameId) + + const data = useMemo((): ManagedGame | null => { + if (!defaultGameId) return null + return { id: defaultGameId, isDefault: true, lastAccessedAt: null } + }, [defaultGameId]) + + return { data } +} + +export function useRecentGames(limit = 10): { data: ManagedGame[] } { + const recentIds = useGameManagementStore((s) => s.recentManagedGameIds) + const defaultGameId = useGameManagementStore((s) => s.defaultGameId) + + const data = useMemo( + () => + recentIds + .slice(-limit) + .reverse() + .map( + (id): ManagedGame => ({ + id, + isDefault: id === defaultGameId, + lastAccessedAt: null, + }), + ), + [recentIds, defaultGameId, limit], + ) + + return { data } +} + +export function useGameMutations() { + return useMemo( + () => ({ + add: (gameId: string) => gameService.add(gameId), + remove: (gameId: string) => gameService.remove(gameId), + setDefault: (gameId: string | null) => gameService.setDefault(gameId), + touch: (gameId: string) => gameService.touch(gameId), + }), + [], + ) +} + +// --------------------------------------------------------------------------- +// Settings hooks +// --------------------------------------------------------------------------- + +export function useGlobalSettings(): { data: GlobalSettings } { + const global = useSettingsStore((s) => s.global) + return { data: global } +} + +export function useGameSettings(gameId: string | null): { data: GameSettings | null } { + const perGame = useSettingsStore((s) => s.perGame) + + const data = useMemo((): GameSettings | null => { + if (!gameId) return null + return useSettingsStore.getState().getPerGame(gameId) + }, [gameId, perGame]) + + return { data } +} + +export function useEffectiveGameSettings( + gameId: string | null, +): { data: EffectiveGameSettings | null } { + const global = useSettingsStore((s) => s.global) + const perGame = useSettingsStore((s) => s.perGame) + + const data = useMemo((): EffectiveGameSettings | null => { + if (!gameId) return null + const pg = useSettingsStore.getState().getPerGame(gameId) + return { + ...pg, + modDownloadFolder: pg.modDownloadFolder || global.modDownloadFolder, + cacheFolder: pg.cacheFolder || global.cacheFolder, + } + }, [gameId, global, perGame]) + + return { data } +} + +export function useSettingsMutations() { + return useMemo( + () => ({ + updateGlobal: (updates: Partial) => + settingsService.updateGlobal(updates), + updateForGame: (gameId: string, updates: Partial) => + settingsService.updateForGame(gameId, updates), + resetForGame: (gameId: string) => settingsService.resetForGame(gameId), + deleteForGame: (gameId: string) => settingsService.deleteForGame(gameId), + }), + [], + ) +} + +// --------------------------------------------------------------------------- +// Profile hooks +// --------------------------------------------------------------------------- + +export function useProfiles(gameId: string | null): { data: Profile[] } { + const profilesByGame = useProfileStore((s) => s.profilesByGame) + + const data = useMemo( + () => (gameId ? profilesByGame[gameId] ?? [] : []), + [gameId, profilesByGame], + ) + + return { data } +} + +export function useActiveProfile(gameId: string | null): { data: Profile | null } { + const profilesByGame = useProfileStore((s) => s.profilesByGame) + const activeMap = useProfileStore((s) => s.activeProfileIdByGame) + + const data = useMemo((): Profile | null => { + if (!gameId) return null + const activeId = activeMap[gameId] + if (!activeId) return null + const profiles = profilesByGame[gameId] ?? [] + return profiles.find((p) => p.id === activeId) ?? null + }, [gameId, profilesByGame, activeMap]) + + return { data } +} + +export function useProfileMutations() { + return useMemo( + () => ({ + ensureDefault: (gameId: string) => profileService.ensureDefault(gameId), + create: (gameId: string, name: string) => profileService.create(gameId, name), + rename: (gameId: string, profileId: string, newName: string) => + profileService.rename(gameId, profileId, newName), + remove: (gameId: string, profileId: string) => + profileService.remove(gameId, profileId), + setActive: (gameId: string, profileId: string) => + profileService.setActive(gameId, profileId), + reset: (gameId: string) => profileService.reset(gameId), + removeAll: (gameId: string) => profileService.removeAll(gameId), + }), + [], + ) +} + +// --------------------------------------------------------------------------- +// Mod hooks +// --------------------------------------------------------------------------- + +export function useInstalledMods(profileId: string | null): { data: InstalledMod[] } { + const installedByProfile = useModManagementStore((s) => s.installedModsByProfile) + const enabledByProfile = useModManagementStore((s) => s.enabledModsByProfile) + const versionsByProfile = useModManagementStore((s) => s.installedModVersionsByProfile) + const warningsByProfile = useModManagementStore((s) => s.dependencyWarningsByProfile) + + const data = useMemo((): InstalledMod[] => { + if (!profileId) return [] + const installed = installedByProfile[profileId] + if (!installed) return [] + + const enabled = enabledByProfile[profileId] + const versions = versionsByProfile[profileId] ?? {} + const warnings = warningsByProfile[profileId] ?? {} + + return Array.from(installed).map( + (modId): InstalledMod => ({ + modId, + installedVersion: versions[modId] ?? "", + enabled: enabled ? enabled.has(modId) : false, + dependencyWarnings: warnings[modId] ?? [], + }), + ) + }, [profileId, installedByProfile, enabledByProfile, versionsByProfile, warningsByProfile]) + + return { data } +} + +export function useIsModInstalled( + profileId: string | null, + modId: string, +): boolean { + const installedByProfile = useModManagementStore((s) => s.installedModsByProfile) + + return useMemo(() => { + if (!profileId) return false + const set = installedByProfile[profileId] + return set ? set.has(modId) : false + }, [profileId, modId, installedByProfile]) +} + +export function useIsModEnabled( + profileId: string | null, + modId: string, +): boolean { + const enabledByProfile = useModManagementStore((s) => s.enabledModsByProfile) + + return useMemo(() => { + if (!profileId) return false + const set = enabledByProfile[profileId] + return set ? set.has(modId) : false + }, [profileId, modId, enabledByProfile]) +} + +export function useModMutations() { + return useMemo( + () => ({ + install: (profileId: string, modId: string, version: string) => + modService.install(profileId, modId, version), + uninstall: (profileId: string, modId: string) => + modService.uninstall(profileId, modId), + uninstallAll: (profileId: string) => modService.uninstallAll(profileId), + enable: (profileId: string, modId: string) => + modService.enable(profileId, modId), + disable: (profileId: string, modId: string) => + modService.disable(profileId, modId), + toggle: (profileId: string, modId: string) => + modService.toggle(profileId, modId), + setDependencyWarnings: (profileId: string, modId: string, warnings: string[]) => + modService.setDependencyWarnings(profileId, modId, warnings), + clearDependencyWarnings: (profileId: string, modId: string) => + modService.clearDependencyWarnings(profileId, modId), + deleteProfileState: (profileId: string) => + modService.deleteProfileState(profileId), + }), + [], + ) +} + +// --------------------------------------------------------------------------- +// Convenience: combined mutation hook for common "unmanage game" flow +// --------------------------------------------------------------------------- + +export function useUnmanageGame() { + const gameMut = useGameMutations() + const settingsMut = useSettingsMutations() + const profileMut = useProfileMutations() + const modMut = useModMutations() + + return useCallback( + async (gameId: string) => { + // 1. Remove all profiles' mod state + const profiles = useProfileStore.getState().profilesByGame[gameId] ?? [] + await Promise.all(profiles.map((p) => modMut.deleteProfileState(p.id))) + + // 2. Remove profiles + await profileMut.removeAll(gameId) + + // 3. Remove per-game settings + await settingsMut.deleteForGame(gameId) + + // 4. Remove game itself (returns next default game id) + return gameMut.remove(gameId) + }, + [gameMut, settingsMut, profileMut, modMut], + ) +} diff --git a/src/data/index.ts b/src/data/index.ts new file mode 100644 index 0000000..e7b65a2 --- /dev/null +++ b/src/data/index.ts @@ -0,0 +1,60 @@ +/** + * Data layer entry point. + * + * Swap the implementation here when migrating from Zustand to DB/tRPC: + * + * - import { createTrpcGameService, ... } from "./trpc" + * + export const gameService = createTrpcGameService(trpcClient) + */ + +import { + createZustandGameService, + createZustandSettingsService, + createZustandProfileService, + createZustandModService, +} from "./zustand" + +// Service singletons – one swap point for the entire app +export const gameService = createZustandGameService() +export const settingsService = createZustandSettingsService() +export const profileService = createZustandProfileService() +export const modService = createZustandModService() + +// Re-export types & interfaces for convenience +export type { + ManagedGame, + Profile, + InstalledMod, + GlobalSettings, + GameSettings, + EffectiveGameSettings, + IGameService, + ISettingsService, + IProfileService, + IModService, +} from "./interfaces" + +// Re-export hooks +export { + // Game + useGames, + useDefaultGame, + useRecentGames, + useGameMutations, + // Settings + useGlobalSettings, + useGameSettings, + useEffectiveGameSettings, + useSettingsMutations, + // Profile + useProfiles, + useActiveProfile, + useProfileMutations, + // Mod + useInstalledMods, + useIsModInstalled, + useIsModEnabled, + useModMutations, + // Compound + useUnmanageGame, +} from "./hooks" diff --git a/src/data/interfaces.ts b/src/data/interfaces.ts new file mode 100644 index 0000000..5f4b2ab --- /dev/null +++ b/src/data/interfaces.ts @@ -0,0 +1,155 @@ +/** + * Data service interfaces. + * + * All methods are async – current Zustand implementation wraps synchronous + * calls in Promises; future DB/tRPC implementation will be natively async. + * + * Components should NOT import these interfaces directly – use the hooks + * from `@/data/hooks` instead. + */ + +// --------------------------------------------------------------------------- +// Domain types +// --------------------------------------------------------------------------- + +export type ManagedGame = { + id: string + isDefault: boolean + lastAccessedAt: number | null +} + +export type { Profile } from "@/store/profile-store" + +export type InstalledMod = { + modId: string + installedVersion: string + enabled: boolean + dependencyWarnings: string[] +} + +export type GlobalSettings = { + // Paths + dataFolder: string + steamFolder: string + modDownloadFolder: string + cacheFolder: string + + // Downloads + speedLimitEnabled: boolean + speedLimitBps: number + speedUnit: "Bps" | "bps" + maxConcurrentDownloads: number + downloadCacheEnabled: boolean + preferredThunderstoreCdn: string + autoInstallMods: boolean + + // Mods + enforceDependencyVersions: boolean + + // UI + cardDisplayType: "collapsed" | "expanded" + theme: "dark" | "light" | "system" + language: string + funkyMode: boolean +} + +export type GameSettings = { + installFolder: string + modDownloadFolder: string + cacheFolder: string + modCacheFolder: string + launchParameters: string + onlineModListCacheDate: number | null +} + +/** + * GameSettings after merging with GlobalSettings defaults. + * Every field is guaranteed non-null. + */ +export type EffectiveGameSettings = GameSettings & { + /** Resolved from per-game → global → default */ + modDownloadFolder: string + /** Resolved from per-game → global → default */ + cacheFolder: string +} + +// --------------------------------------------------------------------------- +// Service interfaces +// --------------------------------------------------------------------------- + +export interface IGameService { + // Queries + list(): Promise + getDefault(): Promise + getRecent(limit?: number): Promise + + // Mutations + add(gameId: string): Promise + remove(gameId: string): Promise + setDefault(gameId: string | null): Promise + touch(gameId: string): Promise +} + +export interface ISettingsService { + // Queries + getGlobal(): Promise + getForGame(gameId: string): Promise + getEffective(gameId: string): Promise + + // Mutations + updateGlobal(updates: Partial): Promise + updateForGame(gameId: string, updates: Partial): Promise + resetForGame(gameId: string): Promise + deleteForGame(gameId: string): Promise +} + +export interface IProfileService { + // Queries + list(gameId: string): Promise + getActive(gameId: string): Promise + + // Mutations + ensureDefault(gameId: string): Promise + create(gameId: string, name: string): Promise + rename(gameId: string, profileId: string, newName: string): Promise + remove( + gameId: string, + profileId: string, + ): Promise<{ deleted: boolean; reason?: string }> + setActive(gameId: string, profileId: string): Promise + reset(gameId: string): Promise + removeAll(gameId: string): Promise +} + +export interface IModService { + // Queries + listInstalled(profileId: string): Promise + isInstalled(profileId: string, modId: string): Promise + isEnabled(profileId: string, modId: string): Promise + getInstalledVersion( + profileId: string, + modId: string, + ): Promise + getDependencyWarnings( + profileId: string, + modId: string, + ): Promise + + // Mutations + install(profileId: string, modId: string, version: string): Promise + uninstall(profileId: string, modId: string): Promise + uninstallAll(profileId: string): Promise + enable(profileId: string, modId: string): Promise + disable(profileId: string, modId: string): Promise + toggle(profileId: string, modId: string): Promise + setDependencyWarnings( + profileId: string, + modId: string, + warnings: string[], + ): Promise + clearDependencyWarnings( + profileId: string, + modId: string, + ): Promise + deleteProfileState(profileId: string): Promise +} diff --git a/src/data/zustand.ts b/src/data/zustand.ts new file mode 100644 index 0000000..e295775 --- /dev/null +++ b/src/data/zustand.ts @@ -0,0 +1,254 @@ +/** + * Zustand-backed implementations of the data service interfaces. + * + * Every method wraps synchronous Zustand store access in a Promise so the + * call-site contract is always async. When we migrate to a real DB backend + * these functions get replaced – nothing else changes. + */ + +import { useGameManagementStore } from "@/store/game-management-store" +import { useSettingsStore } from "@/store/settings-store" +import { useProfileStore } from "@/store/profile-store" +import { useModManagementStore } from "@/store/mod-management-store" +import type { + IGameService, + ISettingsService, + IProfileService, + IModService, + ManagedGame, + InstalledMod, + EffectiveGameSettings, +} from "./interfaces" + +// --------------------------------------------------------------------------- +// Game service +// --------------------------------------------------------------------------- + +export function createZustandGameService(): IGameService { + const store = () => useGameManagementStore.getState() + + return { + async list() { + const s = store() + return s.managedGameIds.map( + (id): ManagedGame => ({ + id, + isDefault: s.defaultGameId === id, + lastAccessedAt: null, // current store has no per-game timestamp + }), + ) + }, + + async getDefault() { + const s = store() + if (!s.defaultGameId) return null + return { + id: s.defaultGameId, + isDefault: true, + lastAccessedAt: null, + } + }, + + async getRecent(limit = 10) { + const s = store() + return s.recentManagedGameIds + .slice(-limit) + .reverse() + .map( + (id): ManagedGame => ({ + id, + isDefault: s.defaultGameId === id, + lastAccessedAt: null, + }), + ) + }, + + async add(gameId) { + store().addManagedGame(gameId) + }, + + async remove(gameId) { + return store().removeManagedGame(gameId) + }, + + async setDefault(gameId) { + store().setDefaultGameId(gameId) + }, + + async touch(gameId) { + store().appendRecentManagedGame(gameId) + }, + } +} + +// --------------------------------------------------------------------------- +// Settings service +// --------------------------------------------------------------------------- + +export function createZustandSettingsService(): ISettingsService { + const store = () => useSettingsStore.getState() + + return { + async getGlobal() { + return { ...store().global } + }, + + async getForGame(gameId) { + return store().getPerGame(gameId) + }, + + async getEffective(gameId): Promise { + const s = store() + const global = s.global + const perGame = s.getPerGame(gameId) + + return { + ...perGame, + modDownloadFolder: perGame.modDownloadFolder || global.modDownloadFolder, + cacheFolder: perGame.cacheFolder || global.cacheFolder, + } + }, + + async updateGlobal(updates) { + store().updateGlobal(updates) + }, + + async updateForGame(gameId, updates) { + store().updatePerGame(gameId, updates) + }, + + async resetForGame(gameId) { + store().resetPerGame(gameId) + }, + + async deleteForGame(gameId) { + store().deletePerGame(gameId) + }, + } +} + +// --------------------------------------------------------------------------- +// Profile service +// --------------------------------------------------------------------------- + +export function createZustandProfileService(): IProfileService { + const store = () => useProfileStore.getState() + + return { + async list(gameId) { + const s = store() + return s.profilesByGame[gameId] ?? [] + }, + + async getActive(gameId) { + const s = store() + const activeId = s.activeProfileIdByGame[gameId] + if (!activeId) return null + const profiles = s.profilesByGame[gameId] ?? [] + return profiles.find((p) => p.id === activeId) ?? null + }, + + async ensureDefault(gameId) { + return store().ensureDefaultProfile(gameId) + }, + + async create(gameId, name) { + return store().createProfile(gameId, name) + }, + + async rename(gameId, profileId, newName) { + store().renameProfile(gameId, profileId, newName) + }, + + async remove(gameId, profileId) { + return store().deleteProfile(gameId, profileId) + }, + + async setActive(gameId, profileId) { + store().setActiveProfile(gameId, profileId) + }, + + async reset(gameId) { + return store().resetGameProfilesToDefault(gameId) + }, + + async removeAll(gameId) { + store().removeGameProfiles(gameId) + }, + } +} + +// --------------------------------------------------------------------------- +// Mod service +// --------------------------------------------------------------------------- + +export function createZustandModService(): IModService { + const store = () => useModManagementStore.getState() + + return { + async listInstalled(profileId) { + const s = store() + const modIds = s.getInstalledModIds(profileId) + return modIds.map( + (modId): InstalledMod => ({ + modId, + installedVersion: s.getInstalledVersion(profileId, modId) ?? "", + enabled: s.isModEnabled(profileId, modId), + dependencyWarnings: s.getDependencyWarnings(profileId, modId), + }), + ) + }, + + async isInstalled(profileId, modId) { + return store().isModInstalled(profileId, modId) + }, + + async isEnabled(profileId, modId) { + return store().isModEnabled(profileId, modId) + }, + + async getInstalledVersion(profileId, modId) { + return store().getInstalledVersion(profileId, modId) + }, + + async getDependencyWarnings(profileId, modId) { + return store().getDependencyWarnings(profileId, modId) + }, + + async install(profileId, modId, version) { + store().installMod(profileId, modId, version) + }, + + async uninstall(profileId, modId) { + await store().uninstallMod(profileId, modId) + }, + + async uninstallAll(profileId) { + return store().uninstallAllMods(profileId) + }, + + async enable(profileId, modId) { + store().enableMod(profileId, modId) + }, + + async disable(profileId, modId) { + store().disableMod(profileId, modId) + }, + + async toggle(profileId, modId) { + store().toggleMod(profileId, modId) + }, + + async setDependencyWarnings(profileId, modId, warnings) { + store().setDependencyWarnings(profileId, modId, warnings) + }, + + async clearDependencyWarnings(profileId, modId) { + store().clearDependencyWarnings(profileId, modId) + }, + + async deleteProfileState(profileId) { + store().deleteProfileState(profileId) + }, + } +} From 312d083f7416e2111352b2878c4638a669c667d7 Mon Sep 17 00:00:00 2001 From: akarachen Date: Fri, 6 Feb 2026 22:35:14 +0800 Subject: [PATCH 2/6] init: add interface implement, need more test --- src/components/app-bootstrap.tsx | 45 +- src/components/download-bridge.tsx | 60 +- src/components/features/add-game-dialog.tsx | 16 +- .../config-editor/config-editor-center.tsx | 7 +- .../dependency-download-dialog.tsx | 20 +- src/components/features/downloads-page.tsx | 7 +- src/components/features/game-dashboard.tsx | 58 +- src/components/features/mod-inspector.tsx | 32 +- src/components/features/mod-list-item.tsx | 25 +- src/components/features/mod-tile.tsx | 25 +- src/components/features/mods-library.tsx | 24 +- .../settings/panels/downloads-panel.tsx | 6 +- .../settings/panels/game-settings-panel.tsx | 101 ++-- .../settings/panels/locations-panel.tsx | 9 +- .../features/settings/panels/other-panel.tsx | 6 +- .../features/settings/settings-dialog.tsx | 4 +- src/components/layout/global-rail.tsx | 57 +- src/data/hooks.ts | 516 ++++++++++-------- src/data/index.ts | 59 +- src/data/interfaces.ts | 2 +- src/data/services.ts | 18 + src/hooks/use-mod-actions.ts | 4 +- src/hooks/use-mod-installer.ts | 5 +- src/main.tsx | 2 + 24 files changed, 569 insertions(+), 539 deletions(-) create mode 100644 src/data/services.ts diff --git a/src/components/app-bootstrap.tsx b/src/components/app-bootstrap.tsx index 7ed3d9a..75086b0 100644 --- a/src/components/app-bootstrap.tsx +++ b/src/components/app-bootstrap.tsx @@ -1,26 +1,29 @@ import { useEffect, useRef } from "react" import { useAppStore } from "@/store/app-store" -import { useGameManagementStore } from "@/store/game-management-store" -import { useProfileStore } from "@/store/profile-store" -import { useSettingsStore } from "@/store/settings-store" +import { + useGameManagementData, + useGameManagementActions, + useProfileActions, + useSettingsData, + useSettingsActions, +} from "@/data" import { DownloadBridge } from "@/components/download-bridge" import { trpc, hasElectronTRPC } from "@/lib/trpc" import { i18n } from "@/lib/i18n" export function AppBootstrap() { const hasInitialized = useRef(false) - const defaultGameId = useGameManagementStore((s) => s.defaultGameId) - const addManagedGame = useGameManagementStore((s) => s.addManagedGame) - const ensureDefaultProfile = useProfileStore((s) => s.ensureDefaultProfile) + const { defaultGameId } = useGameManagementData() + const gameMut = useGameManagementActions() + const profileMut = useProfileActions() const selectGame = useAppStore((s) => s.selectGame) - const getPerGameSettings = useSettingsStore((s) => s.getPerGame) - const globalSettings = useSettingsStore((s) => s.global) - const updateGlobal = useSettingsStore((s) => s.updateGlobal) + const { global: globalSettings, getPerGame } = useSettingsData() + const { updateGlobal } = useSettingsActions() useEffect(() => { void i18n.changeLanguage(globalSettings.language) }, [globalSettings.language]) - + // Fetch default paths from Electron (only in Electron mode) const defaultPathsQuery = trpc.desktop.getDefaultPaths.useQuery(undefined, { enabled: hasElectronTRPC(), @@ -30,20 +33,20 @@ export function AppBootstrap() { // Initialize/migrate settings with Electron defaults useEffect(() => { if (!hasElectronTRPC() || !defaultPathsQuery.data) return - + const { dataFolder: electronDataFolder, steamFolder: electronSteamFolder } = defaultPathsQuery.data const updates: Partial = {} - + // Migrate dataFolder if unset or still on old placeholder if (!globalSettings.dataFolder || globalSettings.dataFolder === "E:\\lmao") { updates.dataFolder = electronDataFolder } - + // Set steamFolder if unset and we found one if (!globalSettings.steamFolder && electronSteamFolder) { updates.steamFolder = electronSteamFolder } - + // Apply updates if any if (Object.keys(updates).length > 0) { updateGlobal(updates) @@ -53,19 +56,19 @@ export function AppBootstrap() { useEffect(() => { if (hasInitialized.current || !defaultGameId) return hasInitialized.current = true - + // Ensure game is managed - addManagedGame(defaultGameId) - + gameMut.addManagedGame(defaultGameId) + // Only ensure profile if game has install folder set - const installFolder = getPerGameSettings(defaultGameId).gameInstallFolder + const installFolder = getPerGame(defaultGameId).gameInstallFolder if (installFolder?.trim()) { - ensureDefaultProfile(defaultGameId) + profileMut.ensureDefaultProfile(defaultGameId) } - + // Select the game selectGame(defaultGameId) }, []) - + return } diff --git a/src/components/download-bridge.tsx b/src/components/download-bridge.tsx index 0eb50d8..fc13642 100644 --- a/src/components/download-bridge.tsx +++ b/src/components/download-bridge.tsx @@ -1,21 +1,20 @@ /** * DownloadBridge - Single IPC subscription point for download events - * + * * This component solves the N-duplicate-subscriptions problem by: * 1. Mounting exactly once in the app bootstrap * 2. Being the ONLY place that subscribes to window.electron IPC events * 3. Updating Zustand store when events arrive (store notifies all components) * 4. Syncing settings to main process when they change - * - * Before: Each component calling useDownloadActions() created a subscription → N subscriptions - * After: One bridge component → 1 subscription → massive perf improvement */ import { useEffect, useRef } from "react" import { toast } from "sonner" import { useDownloadStore } from "@/store/download-store" +// Keep store imports for imperative getState() inside IPC callbacks import { useSettingsStore } from "@/store/settings-store" import { useProfileStore } from "@/store/profile-store" import { useModManagementStore } from "@/store/mod-management-store" +import { useSettingsData } from "@/data" import { trpc } from "@/lib/trpc" type DownloadUpdateEvent = { @@ -36,21 +35,14 @@ type DownloadProgressEvent = { export function DownloadBridge() { const updateTask = useDownloadStore((s) => s._updateTask) - - // Get individual settings for syncing to main process - const maxConcurrent = useSettingsStore((s) => s.global.maxConcurrentDownloads) - const speedLimitEnabled = useSettingsStore((s) => s.global.speedLimitEnabled) - const speedLimitBps = useSettingsStore((s) => s.global.speedLimitBps) - - // Get path settings for syncing to main process - const dataFolder = useSettingsStore((s) => s.global.dataFolder) - const modDownloadFolder = useSettingsStore((s) => s.global.modDownloadFolder) - const cacheFolder = useSettingsStore((s) => s.global.cacheFolder) - const perGame = useSettingsStore((s) => s.perGame) - + + // Reactive settings via data hooks (for syncing to main process) + const { global: globalSettings, perGame } = useSettingsData() + const { maxConcurrentDownloads: maxConcurrent, speedLimitEnabled, speedLimitBps, dataFolder, modDownloadFolder, cacheFolder } = globalSettings + const updateSettingsMutation = trpc.downloads.updateSettings.useMutation() const installModMutation = trpc.profiles.installMod.useMutation() - + // Sync settings to main process when they change useEffect(() => { // Filter perGame to only include path-related settings @@ -62,7 +54,7 @@ export function DownloadBridge() { modCacheFolder: settings.modCacheFolder || "", } } - + updateSettingsMutation.mutate({ maxConcurrent, speedLimitBps: speedLimitEnabled ? speedLimitBps : 0, @@ -77,17 +69,17 @@ export function DownloadBridge() { }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [maxConcurrent, speedLimitEnabled, speedLimitBps, dataFolder, modDownloadFolder, cacheFolder, perGame]) - + // Single IPC subscription (with StrictMode guard to prevent double-subscription) const subscriptionActiveRef = useRef(false) - + useEffect(() => { // Guard against StrictMode double-mounting if (subscriptionActiveRef.current) return if (!window.electron) return - + subscriptionActiveRef.current = true - + // Handler for download status updates (queued → downloading → paused → completed/error) const handleDownloadUpdated = (data: unknown) => { const event = data as DownloadUpdateEvent @@ -115,7 +107,7 @@ export function DownloadBridge() { progress, }) } - + // Handler for download completion const handleDownloadCompleted = async (data: unknown) => { const event = data as { @@ -135,21 +127,21 @@ export function DownloadBridge() { archivePath: event.result?.archivePath, extractedPath: event.result?.extractedPath, }) - + // Get task info for toast and auto-install const task = useDownloadStore.getState().getTask(event.downloadId) if (!task) return - + // Check if auto-install is enabled (read from store directly to get latest value) const autoInstallEnabled = useSettingsStore.getState().global.autoInstallMods if (autoInstallEnabled && event.result?.extractedPath) { // Get active profile for this game const activeProfileId = useProfileStore.getState().activeProfileIdByGame[task.gameId] - + if (activeProfileId) { // Check if mod is already installed const isAlreadyInstalled = useModManagementStore.getState().isModInstalled(activeProfileId, task.modId) - + if (!isAlreadyInstalled) { try { // Auto-install the mod @@ -162,10 +154,10 @@ export function DownloadBridge() { version: task.modVersion, extractedPath: event.result.extractedPath, }) - + // Mark as installed in state useModManagementStore.getState().installMod(activeProfileId, task.modId, task.modVersion) - + // Show success toast toast.success(`${task.modName} installed`, { description: `v${task.modVersion} - ${result.filesCopied} files copied to profile`, @@ -196,7 +188,7 @@ export function DownloadBridge() { }) } } - + // Handler for download failure const handleDownloadFailed = (data: unknown) => { const event = data as { downloadId: string; error: string } @@ -205,7 +197,7 @@ export function DownloadBridge() { error: event.error, speedBps: 0, }) - + // Show error toast const task = useDownloadStore.getState().getTask(event.downloadId) if (task) { @@ -214,13 +206,13 @@ export function DownloadBridge() { }) } } - + // Subscribe to all IPC events const unsubUpdated = window.electron.onDownloadUpdated(handleDownloadUpdated) const unsubProgress = window.electron.onDownloadProgress(handleDownloadProgress) const unsubCompleted = window.electron.onDownloadCompleted(handleDownloadCompleted) const unsubFailed = window.electron.onDownloadFailed(handleDownloadFailed) - + // Cleanup: unsubscribe all return () => { subscriptionActiveRef.current = false @@ -230,7 +222,7 @@ export function DownloadBridge() { unsubFailed() } }, [updateTask]) - + // This is an invisible bridge component return null } diff --git a/src/components/features/add-game-dialog.tsx b/src/components/features/add-game-dialog.tsx index bf2ba03..940b4b1 100644 --- a/src/components/features/add-game-dialog.tsx +++ b/src/components/features/add-game-dialog.tsx @@ -17,9 +17,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" import { Input } from "@/components/ui/input" import { Button } from "@/components/ui/button" import { useAppStore } from "@/store/app-store" -import { useGameManagementStore } from "@/store/game-management-store" -import { useProfileStore } from "@/store/profile-store" -import { useSettingsStore } from "@/store/settings-store" +import { useGameManagementActions, useProfileActions, useSettingsActions } from "@/data" import { ECOSYSTEM_GAMES, type EcosystemGame } from "@/lib/ecosystem-games" import { selectFolder } from "@/lib/desktop" import { CreateProfileDialog } from "./create-profile-dialog" @@ -43,16 +41,10 @@ export function AddGameDialog({ open, onOpenChange, forceOpen = false }: AddGame const [createProfileOpen, setCreateProfileOpen] = useState(false) const [selectedProfileId, setSelectedProfileId] = useState(null) - const addManagedGame = useGameManagementStore((s) => s.addManagedGame) - const appendRecentManagedGame = useGameManagementStore( - (s) => s.appendRecentManagedGame - ) - const setDefaultGameId = useGameManagementStore((s) => s.setDefaultGameId) - const ensureDefaultProfile = useProfileStore((s) => s.ensureDefaultProfile) - const createProfile = useProfileStore((s) => s.createProfile) - const setActiveProfile = useProfileStore((s) => s.setActiveProfile) + const { addManagedGame, appendRecentManagedGame, setDefaultGameId } = useGameManagementActions() + const { ensureDefaultProfile, createProfile, setActiveProfile } = useProfileActions() const selectGame = useAppStore((s) => s.selectGame) - const updatePerGameSettings = useSettingsStore((s) => s.updatePerGame) + const { updatePerGame: updatePerGameSettings } = useSettingsActions() const filteredGames = ECOSYSTEM_GAMES.filter((game) => game.name.toLowerCase().includes(query.toLowerCase()) diff --git a/src/components/features/config-editor/config-editor-center.tsx b/src/components/features/config-editor/config-editor-center.tsx index 6fe7990..9eabff3 100644 --- a/src/components/features/config-editor/config-editor-center.tsx +++ b/src/components/features/config-editor/config-editor-center.tsx @@ -12,7 +12,7 @@ import { parseBepInExConfig, parseIniConfig, updateConfigValue, updateIniValue, import { logger } from "@/lib/logger" import { trpc } from "@/lib/trpc" import { useAppStore } from "@/store/app-store" -import { useProfileStore } from "@/store/profile-store" +import { useProfileData } from "@/data" import { toast } from "sonner" // Lazy load Monaco editor @@ -33,9 +33,8 @@ type FileFormat = "cfg" | "yaml" | "yml" | "json" | "ini" | "txt" export function ConfigEditorCenter() { const { t } = useTranslation() const selectedGameId = useAppStore((s) => s.selectedGameId) - const activeProfileId = useProfileStore((s) => - selectedGameId ? s.activeProfileIdByGame[selectedGameId] : null - ) + const { activeProfileIdByGame } = useProfileData() + const activeProfileId = selectedGameId ? activeProfileIdByGame[selectedGameId] ?? null : null const [selectedRelativePath, setSelectedRelativePath] = useState(null) const [mode, setMode] = useState<"gui" | "raw">("gui") diff --git a/src/components/features/dependencies/dependency-download-dialog.tsx b/src/components/features/dependencies/dependency-download-dialog.tsx index 915273d..a7e4844 100644 --- a/src/components/features/dependencies/dependency-download-dialog.tsx +++ b/src/components/features/dependencies/dependency-download-dialog.tsx @@ -16,9 +16,7 @@ import { Badge } from "@/components/ui/badge" import { Separator } from "@/components/ui/separator" import { Skeleton } from "@/components/ui/skeleton" import { analyzeModDependencies, type DependencyStatus } from "@/lib/dependency-utils" -import { useModManagementStore } from "@/store/mod-management-store" -import { useProfileStore } from "@/store/profile-store" -import { useSettingsStore } from "@/store/settings-store" +import { useModManagementData, useModManagementActions, useProfileData, useSettingsData } from "@/data" import { useDownloadActions } from "@/hooks/use-download-actions" import { useOnlineDependenciesRecursive } from "@/lib/queries/useOnlineMods" import { MODS } from "@/mocks/mods" @@ -102,11 +100,11 @@ function getStatusVariant(status: DependencyStatus): "default" | "secondary" | " export const DependencyDownloadDialog = memo(function DependencyDownloadDialog({ mod, requestedVersion, open, onOpenChange }: DependencyDownloadDialogProps) { const { t } = useTranslation() - const activeProfileId = useProfileStore((s) => mod ? s.activeProfileIdByGame[mod.gameId] : undefined) - const installedVersionsByProfile = useModManagementStore((s) => s.installedModVersionsByProfile) - const installedModsByProfile = useModManagementStore((s) => s.installedModsByProfile) - const setDependencyWarnings = useModManagementStore((s) => s.setDependencyWarnings) - const enforceDependencyVersions = useSettingsStore((s) => s.global.enforceDependencyVersions) + const { activeProfileIdByGame } = useProfileData() + const activeProfileId = mod ? activeProfileIdByGame[mod.gameId] : undefined + const { installedModVersionsByProfile, installedModsByProfile } = useModManagementData() + const { setDependencyWarnings } = useModManagementActions() + const { global: { enforceDependencyVersions } } = useSettingsData() const { startDownload } = useDownloadActions() // null means "auto-select everything that needs downloading" @@ -118,7 +116,7 @@ export const DependencyDownloadDialog = memo(function DependencyDownloadDialog({ const isThunderstoreMod = mod ? (mod.id.length === 36 && mod.id.includes("-")) : false // Get installed versions for the active profile - const installedVersionsForProfile = activeProfileId ? installedVersionsByProfile[activeProfileId] : undefined + const installedVersionsForProfile = activeProfileId ? installedModVersionsByProfile[activeProfileId] : undefined // Use recursive online dependency resolution for Thunderstore mods in Electron const recursiveDepsQuery = useOnlineDependenciesRecursive({ @@ -204,7 +202,7 @@ export const DependencyDownloadDialog = memo(function DependencyDownloadDialog({ } // Fallback to non-recursive mock catalog analysis - const installedVersions = installedVersionsByProfile[activeProfileId] || {} + const installedVersions = installedModVersionsByProfile[activeProfileId] || {} const depInfos = analyzeModDependencies({ mod, mods: MODS, @@ -248,7 +246,7 @@ export const DependencyDownloadDialog = memo(function DependencyDownloadDialog({ childrenByKey: {} as Record, isLoadingDeps: false, } - }, [mod, activeProfileId, isThunderstoreMod, recursiveDepsQuery.isElectron, recursiveDepsQuery.isLoading, recursiveDepsQuery.data, installedVersionsByProfile, enforceDependencyVersions]) + }, [mod, activeProfileId, isThunderstoreMod, recursiveDepsQuery.isElectron, recursiveDepsQuery.isLoading, recursiveDepsQuery.data, installedModVersionsByProfile, enforceDependencyVersions]) // Flatten all deps for easier access const allDeps = useMemo(() => { diff --git a/src/components/features/downloads-page.tsx b/src/components/features/downloads-page.tsx index ee7efc4..d18e828 100644 --- a/src/components/features/downloads-page.tsx +++ b/src/components/features/downloads-page.tsx @@ -7,8 +7,7 @@ import { cn } from "@/lib/utils" import { useDownloadStore, type DownloadTask } from "@/store/download-store" import { useDownloadActions } from "@/hooks/use-download-actions" import { useModInstaller } from "@/hooks/use-mod-installer" -import { useModManagementStore } from "@/store/mod-management-store" -import { useProfileStore } from "@/store/profile-store" +import { useModManagementData, useProfileData } from "@/data" import { ECOSYSTEM_GAMES } from "@/lib/ecosystem-games" import { openFolder } from "@/lib/desktop" @@ -83,8 +82,8 @@ export function DownloadsPage() { } = useDownloadActions() const { installDownloadedMod, isInstalling } = useModInstaller() - const activeProfileIdByGame = useProfileStore((s) => s.activeProfileIdByGame) - const isModInstalled = useModManagementStore((s) => s.isModInstalled) + const { activeProfileIdByGame } = useProfileData() + const { isModInstalled } = useModManagementData() const allTasks = Object.values(tasks) const activeTasks = getAllActiveTasks() diff --git a/src/components/features/game-dashboard.tsx b/src/components/features/game-dashboard.tsx index 04de5f1..9c3892b 100644 --- a/src/components/features/game-dashboard.tsx +++ b/src/components/features/game-dashboard.tsx @@ -3,9 +3,8 @@ import { useTranslation } from "react-i18next" import { Plus, Upload, Download as DownloadIcon, ChevronDown, Settings, FolderOpen, FileCode, FileDown, Edit, Trash2 } from "lucide-react" import { useAppStore } from "@/store/app-store" -import { useProfileStore, type Profile } from "@/store/profile-store" -import { useModManagementStore } from "@/store/mod-management-store" -import { useSettingsStore } from "@/store/settings-store" +import { useProfileData, useProfileActions, useModManagementData, useModManagementActions, useSettingsData } from "@/data" +import type { Profile } from "@/store/profile-store" import { trpc } from "@/lib/trpc" import { getExeNames, getEcosystemEntry, getModloaderPackageForGame } from "@/lib/ecosystem" import { useCatalogStatus } from "@/lib/queries/useOnlineMods" @@ -44,22 +43,13 @@ export function GameDashboard() { const selectedGameId = useAppStore((s) => s.selectedGameId) const openSettingsToGame = useAppStore((s) => s.openSettingsToGame) - const activeProfileId = useProfileStore( - (s) => selectedGameId ? s.activeProfileIdByGame[selectedGameId] : undefined - ) - const setActiveProfile = useProfileStore((s) => s.setActiveProfile) - const createProfile = useProfileStore((s) => s.createProfile) - const renameProfile = useProfileStore((s) => s.renameProfile) - const deleteProfile = useProfileStore((s) => s.deleteProfile) - const ensureDefaultProfile = useProfileStore((s) => s.ensureDefaultProfile) - // Avoid returning new [] in selector - return undefined and default outside - const profilesFromStore = useProfileStore((s) => selectedGameId ? s.profilesByGame[selectedGameId] : undefined) - const profiles = profilesFromStore ?? EMPTY_PROFILES - - const deleteProfileState = useModManagementStore((s) => s.deleteProfileState) - const uninstallAllMods = useModManagementStore((s) => s.uninstallAllMods) - const installedModsByProfile = useModManagementStore((s) => s.installedModsByProfile) - const installMod = useModManagementStore((s) => s.installMod) + const { activeProfileIdByGame, profilesByGame } = useProfileData() + const profileMut = useProfileActions() + const { installedModsByProfile } = useModManagementData() + const modMut = useModManagementActions() + + const activeProfileId = selectedGameId ? activeProfileIdByGame[selectedGameId] : undefined + const profiles = (selectedGameId ? profilesByGame[selectedGameId] : undefined) ?? EMPTY_PROFILES const resetProfileMutation = trpc.profiles.resetProfile.useMutation() const launchMutation = trpc.launch.start.useMutation() @@ -69,8 +59,8 @@ export function GameDashboard() { const installedModCount = installedModsSet?.size ?? 0 // Check if game binary can be found - const getPerGameSettings = useSettingsStore((s) => s.getPerGame) - const installFolder = selectedGameId ? getPerGameSettings(selectedGameId).gameInstallFolder : "" + const { getPerGame } = useSettingsData() + const installFolder = selectedGameId ? getPerGame(selectedGameId).gameInstallFolder : "" const profilesEnabled = installFolder?.trim().length > 0 const exeNames = selectedGameId ? getExeNames(selectedGameId) : [] const ecosystem = selectedGameId ? getEcosystemEntry(selectedGameId) : null @@ -165,9 +155,9 @@ export function GameDashboard() { // Auto-ensure default profile when install folder becomes valid useEffect(() => { if (profilesEnabled && selectedGameId) { - ensureDefaultProfile(selectedGameId) + profileMut.ensureDefaultProfile(selectedGameId) } - }, [profilesEnabled, selectedGameId, ensureDefaultProfile]) + }, [profilesEnabled, selectedGameId, profileMut]) // Early return if no game selected - MUST be after all hooks if (!selectedGameId) { @@ -233,7 +223,7 @@ export function GameDashboard() { mode: "modded", installFolder, exePath: binaryVerification.data.exePath, - launchParameters: getPerGameSettings(selectedGameId).launchParameters || "", + launchParameters: getPerGame(selectedGameId).launchParameters || "", packageIndexUrl, modloaderPackage: modloaderPackage || undefined, }) @@ -292,7 +282,7 @@ export function GameDashboard() { // Use UUID so metadata loads; fallback to owner-name if UUID is missing. const installedId = installResult.packageUuid4 || installResult.packageId if (installedId && installResult.version) { - installMod(activeProfileId, installedId, installResult.version) + modMut.installMod(activeProfileId, installedId, installResult.version) } toast.success("Base dependencies installed", { @@ -313,7 +303,7 @@ export function GameDashboard() { mode: "modded", installFolder, exePath: binaryVerification.data.exePath, - launchParameters: getPerGameSettings(selectedGameId).launchParameters || "", + launchParameters: getPerGame(selectedGameId).launchParameters || "", packageIndexUrl, modloaderPackage: modloaderPackage || undefined, }) @@ -360,7 +350,7 @@ export function GameDashboard() { // Use UUID so metadata loads; fallback to owner-name if UUID is missing. const installedId = installResult.packageUuid4 || installResult.packageId if (installedId && installResult.version) { - installMod(activeProfileId, installedId, installResult.version) + modMut.installMod(activeProfileId, installedId, installResult.version) } toast.success("Base dependencies installed", { @@ -390,7 +380,7 @@ export function GameDashboard() { mode: "vanilla", installFolder, exePath: binaryVerification.data.exePath, - launchParameters: getPerGameSettings(selectedGameId).launchParameters || "", + launchParameters: getPerGame(selectedGameId).launchParameters || "", packageIndexUrl, modloaderPackage: modloaderPackage || undefined, }) @@ -413,20 +403,20 @@ export function GameDashboard() { } const handleCreateProfile = (profileName: string) => { - createProfile(selectedGameId, profileName) + profileMut.createProfile(selectedGameId, profileName) toast.success("Profile created") } const handleRenameProfile = (newName: string) => { if (!activeProfileId) return - renameProfile(selectedGameId, activeProfileId, newName) + profileMut.renameProfile(selectedGameId, activeProfileId, newName) toast.success("Profile renamed") } const handleDeleteProfile = async () => { if (!activeProfileId) return - const result = deleteProfile(selectedGameId, activeProfileId) + const result = await profileMut.deleteProfile(selectedGameId, activeProfileId) if (!result.deleted) { toast.error("Cannot delete default profile") setDeleteProfileOpen(false) @@ -441,7 +431,7 @@ export function GameDashboard() { }) // Clear state - deleteProfileState(activeProfileId) + modMut.deleteProfileState(activeProfileId) toast.success("Profile deleted", { description: `${resetResult.filesRemoved} files removed from disk`, @@ -467,7 +457,7 @@ export function GameDashboard() { }) // Clear mod state for this profile (but keep the profile itself) - uninstallAllMods(activeProfileId) + modMut.uninstallAllMods(activeProfileId) toast.success("All mods uninstalled", { description: `${result.filesRemoved} files removed from profile`, @@ -559,7 +549,7 @@ export function GameDashboard() { {t("common_all_profiles")} setActiveProfile(selectedGameId, profileId)} + onValueChange={(profileId) => profileMut.setActiveProfile(selectedGameId, profileId)} > {gameProfiles.map((profile) => ( s.toggleMod) + const { toggleMod } = useModManagementActions() const { uninstallMod } = useModActions() - const installedVersionsByProfile = useModManagementStore((s) => s.installedModVersionsByProfile) - const enforceDependencyVersions = useSettingsStore((s) => s.global.enforceDependencyVersions) - + const { installedModsByProfile, enabledModsByProfile, uninstallingMods, installedModVersionsByProfile: installedVersionsByProfile } = useModManagementData() + const { global: { enforceDependencyVersions } } = useSettingsData() + const selectedGameId = useAppStore((s) => s.selectedGameId) - const activeProfileId = useProfileStore((s) => selectedGameId ? s.activeProfileIdByGame[selectedGameId] : undefined) + const { activeProfileIdByGame } = useProfileData() + const activeProfileId = selectedGameId ? activeProfileIdByGame[selectedGameId] : undefined const setSearchQuery = useAppStore((s) => s.setSearchQuery) const setModLibraryTab = useAppStore((s) => s.setModLibraryTab) const selectMod = useAppStore((s) => s.selectMod) @@ -281,14 +280,10 @@ export function ModInspectorContent({ mod, onBack }: ModInspectorContentProps) { // Subscribe to the specific task so component re-renders on changes const downloadTask = useDownloadStore((s) => s.tasks[mod.id]) - // Subscribe to the Sets directly, not derived booleans - const installedSet = useModManagementStore((s) => - activeProfileId ? s.installedModsByProfile[activeProfileId] : undefined - ) - const enabledSet = useModManagementStore((s) => - activeProfileId ? s.enabledModsByProfile[activeProfileId] : undefined - ) - const uninstallingSet = useModManagementStore((s) => s.uninstallingMods) + // Derive Sets from data hooks + const installedSet = activeProfileId ? installedModsByProfile[activeProfileId] : undefined + const enabledSet = activeProfileId ? enabledModsByProfile[activeProfileId] : undefined + const uninstallingSet = uninstallingMods // Derive booleans from Sets const installed = installedSet ? installedSet.has(mod.id) : false @@ -1012,8 +1007,9 @@ export function ModInspector() { const selectedGameId = useAppStore((s) => s.selectedGameId) const selectMod = useAppStore((s) => s.selectMod) - const activeProfileId = useProfileStore((s) => selectedGameId ? s.activeProfileIdByGame[selectedGameId] : undefined) - const installedVersionsByProfile = useModManagementStore((s) => s.installedModVersionsByProfile) + const { activeProfileIdByGame } = useProfileData() + const activeProfileId = selectedGameId ? activeProfileIdByGame[selectedGameId] : undefined + const { installedModVersionsByProfile: installedVersionsByProfile } = useModManagementData() const { uninstallMod } = useModActions() // Check if this is a Thunderstore mod (UUID format: 36 chars with hyphens) diff --git a/src/components/features/mod-list-item.tsx b/src/components/features/mod-list-item.tsx index e5cca59..8398e15 100644 --- a/src/components/features/mod-list-item.tsx +++ b/src/components/features/mod-list-item.tsx @@ -4,9 +4,8 @@ import { memo } from "react" import { useTranslation } from "react-i18next" import { Download, Trash2, Loader2, Pause, AlertTriangle } from "lucide-react" import { useAppStore } from "@/store/app-store" -import { useModManagementStore } from "@/store/mod-management-store" -import { useProfileStore } from "@/store/profile-store" import { useDownloadStore } from "@/store/download-store" +import { useProfileData, useModManagementData, useModManagementActions } from "@/data" import { useDownloadActions } from "@/hooks/use-download-actions" import { useModActions } from "@/hooks/use-mod-actions" import { cn } from "@/lib/utils" @@ -26,25 +25,21 @@ export const ModListItem = memo(function ModListItem({ mod, onOpenDependencyDial const selectedModId = useAppStore((s) => s.selectedModId) const selectedGameId = useAppStore((s) => s.selectedGameId) - const toggleMod = useModManagementStore((s) => s.toggleMod) + const { toggleMod } = useModManagementActions() const { uninstallMod } = useModActions() - const getDependencyWarnings = useModManagementStore((s) => s.getDependencyWarnings) - const installedVersionsByProfile = useModManagementStore((s) => s.installedModVersionsByProfile) - - const activeProfileId = useProfileStore((s) => selectedGameId ? s.activeProfileIdByGame[selectedGameId] : undefined) + const { installedModsByProfile, enabledModsByProfile, uninstallingMods, getDependencyWarnings, installedModVersionsByProfile: installedVersionsByProfile } = useModManagementData() + + const { activeProfileIdByGame } = useProfileData() + const activeProfileId = selectedGameId ? activeProfileIdByGame[selectedGameId] : undefined const { startDownload } = useDownloadActions() const isSelected = selectedModId === mod.id - // Subscribe to the Sets directly, not derived booleans - const installedSet = useModManagementStore((s) => - activeProfileId ? s.installedModsByProfile[activeProfileId] : undefined - ) - const enabledSet = useModManagementStore((s) => - activeProfileId ? s.enabledModsByProfile[activeProfileId] : undefined - ) - const uninstallingSet = useModManagementStore((s) => s.uninstallingMods) + // Derive Sets from data hooks + const installedSet = activeProfileId ? installedModsByProfile[activeProfileId] : undefined + const enabledSet = activeProfileId ? enabledModsByProfile[activeProfileId] : undefined + const uninstallingSet = uninstallingMods // Subscribe to download task const downloadTask = useDownloadStore((s) => s.tasks[mod.id]) diff --git a/src/components/features/mod-tile.tsx b/src/components/features/mod-tile.tsx index de3bfdc..d1de35d 100644 --- a/src/components/features/mod-tile.tsx +++ b/src/components/features/mod-tile.tsx @@ -4,9 +4,8 @@ import { memo } from "react" import { useTranslation } from "react-i18next" import { Download, Trash2, Loader2, Pause, AlertTriangle } from "lucide-react" import { useAppStore } from "@/store/app-store" -import { useModManagementStore } from "@/store/mod-management-store" -import { useProfileStore } from "@/store/profile-store" import { useDownloadStore } from "@/store/download-store" +import { useProfileData, useModManagementData, useModManagementActions } from "@/data" import { useDownloadActions } from "@/hooks/use-download-actions" import { useModActions } from "@/hooks/use-mod-actions" import { cn } from "@/lib/utils" @@ -26,25 +25,21 @@ export const ModTile = memo(function ModTile({ mod, onOpenDependencyDialog }: Mo const selectedModId = useAppStore((s) => s.selectedModId) const selectedGameId = useAppStore((s) => s.selectedGameId) - const toggleMod = useModManagementStore((s) => s.toggleMod) + const { toggleMod } = useModManagementActions() const { uninstallMod } = useModActions() - const getDependencyWarnings = useModManagementStore((s) => s.getDependencyWarnings) - const installedVersionsByProfile = useModManagementStore((s) => s.installedModVersionsByProfile) - - const activeProfileId = useProfileStore((s) => selectedGameId ? s.activeProfileIdByGame[selectedGameId] : undefined) + const { installedModsByProfile, enabledModsByProfile, uninstallingMods, getDependencyWarnings, installedModVersionsByProfile: installedVersionsByProfile } = useModManagementData() + + const { activeProfileIdByGame } = useProfileData() + const activeProfileId = selectedGameId ? activeProfileIdByGame[selectedGameId] : undefined const { startDownload } = useDownloadActions() const isSelected = selectedModId === mod.id - // Subscribe to the Sets directly, not derived booleans - const installedSet = useModManagementStore((s) => - activeProfileId ? s.installedModsByProfile[activeProfileId] : undefined - ) - const enabledSet = useModManagementStore((s) => - activeProfileId ? s.enabledModsByProfile[activeProfileId] : undefined - ) - const uninstallingSet = useModManagementStore((s) => s.uninstallingMods) + // Derive Sets from data hooks + const installedSet = activeProfileId ? installedModsByProfile[activeProfileId] : undefined + const enabledSet = activeProfileId ? enabledModsByProfile[activeProfileId] : undefined + const uninstallingSet = uninstallingMods // Subscribe to download task const downloadTask = useDownloadStore((s) => s.tasks[mod.id]) diff --git a/src/components/features/mods-library.tsx b/src/components/features/mods-library.tsx index edb1712..2f121ae 100644 --- a/src/components/features/mods-library.tsx +++ b/src/components/features/mods-library.tsx @@ -4,9 +4,8 @@ import { Search, SlidersHorizontal, MoreVertical, ChevronDown, Plus, Grid3x3, Li import { useVirtualizer } from "@tanstack/react-virtual" import { useAppStore } from "@/store/app-store" -import { useProfileStore, type Profile } from "@/store/profile-store" -import { useModManagementStore } from "@/store/mod-management-store" -import { useSettingsStore } from "@/store/settings-store" +import type { Profile } from "@/store/profile-store" +import { useProfileData, useProfileActions, useModManagementData, useModManagementActions, useSettingsData } from "@/data" import { MODS } from "@/mocks/mods" import { ECOSYSTEM_GAMES } from "@/lib/ecosystem-games" import { MOD_CATEGORIES } from "@/mocks/mod-categories" @@ -503,27 +502,22 @@ export function ModsLibrary() { const setTab = useAppStore((s) => s.setModLibraryTab) // Subscribe to the installed mods Set directly for real-time updates - const activeProfileId = useProfileStore((s) => - selectedGameId ? s.activeProfileIdByGame[selectedGameId] ?? null : null - ) - const installedModsByProfile = useModManagementStore((s) => s.installedModsByProfile) - const installedModVersionsByProfile = useModManagementStore((s) => s.installedModVersionsByProfile) - const installMod = useModManagementStore((s) => s.installMod) + const { activeProfileIdByGame, profilesByGame: profilesByGameFromData } = useProfileData() + const activeProfileId = selectedGameId ? activeProfileIdByGame[selectedGameId] ?? null : null + const { installedModsByProfile, installedModVersionsByProfile } = useModManagementData() + const { installMod } = useModManagementActions() // Use stable fallback to avoid new Set() every render const installedModsSet = activeProfileId ? installedModsByProfile[activeProfileId] : undefined const installedModsSetOrEmpty = installedModsSet ?? EMPTY_SET const installedVersionsMap = activeProfileId ? installedModVersionsByProfile[activeProfileId] : undefined // Avoid returning new [] in selector - return undefined and default outside - const profilesFromStore = useProfileStore((s) => - selectedGameId ? s.profilesByGame[selectedGameId] ?? undefined : undefined - ) + const profilesFromStore = selectedGameId ? profilesByGameFromData[selectedGameId] ?? undefined : undefined const profiles = profilesFromStore ?? EMPTY_PROFILES - const createProfile = useProfileStore((s) => s.createProfile) - const setActiveProfile = useProfileStore((s) => s.setActiveProfile) + const { createProfile, setActiveProfile } = useProfileActions() // Check if profiles are enabled (requires install folder) - const getPerGameSettings = useSettingsStore((s) => s.getPerGame) + const { getPerGame: getPerGameSettings } = useSettingsData() const installFolder = selectedGameId ? getPerGameSettings(selectedGameId).gameInstallFolder : "" const profilesEnabled = installFolder?.trim().length > 0 const exeNames = selectedGameId ? getExeNames(selectedGameId) : [] diff --git a/src/components/features/settings/panels/downloads-panel.tsx b/src/components/features/settings/panels/downloads-panel.tsx index fda475c..8b825af 100644 --- a/src/components/features/settings/panels/downloads-panel.tsx +++ b/src/components/features/settings/panels/downloads-panel.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next" -import { useSettingsStore } from "@/store/settings-store" +import { useSettingsData, useSettingsActions } from "@/data" import { SettingsRow } from "../settings-row" import { Switch } from "@/components/ui/switch" import { Slider } from "@/components/ui/slider" @@ -37,8 +37,8 @@ function formatSpeed(bps: number, unit: "Bps" | "bps", t: (key: string) => strin export function DownloadsPanel(_props: PanelProps) { const { t } = useTranslation() - const { speedLimitEnabled, speedLimitBps, speedUnit, maxConcurrentDownloads, downloadCacheEnabled, autoInstallMods } = useSettingsStore((s) => s.global) - const updateGlobal = useSettingsStore((s) => s.updateGlobal) + const { speedLimitEnabled, speedLimitBps, speedUnit, maxConcurrentDownloads, downloadCacheEnabled, autoInstallMods } = useSettingsData().global + const { updateGlobal } = useSettingsActions() // Logarithmic slider mapping (10 KB/s to 200 MB/s) const minBps = 10 * 1024 // 10 KB/s diff --git a/src/components/features/settings/panels/game-settings-panel.tsx b/src/components/features/settings/panels/game-settings-panel.tsx index 0f27a4a..cba1cae 100644 --- a/src/components/features/settings/panels/game-settings-panel.tsx +++ b/src/components/features/settings/panels/game-settings-panel.tsx @@ -3,10 +3,14 @@ import { SettingsRow } from "../settings-row" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { FolderPathControl } from "../folder-path-control" -import { useProfileStore } from "@/store/profile-store" -import { useSettingsStore } from "@/store/settings-store" -import { useModManagementStore } from "@/store/mod-management-store" -import { useGameManagementStore } from "@/store/game-management-store" +import { + useSettingsData, + useSettingsActions, + useProfileData, + useProfileActions, + useModManagementActions, + useGameManagementActions, +} from "@/data" import { useAppStore } from "@/store/app-store" import { openFolder } from "@/lib/desktop" import { ECOSYSTEM_GAMES } from "@/lib/ecosystem-games" @@ -20,24 +24,19 @@ interface GameSettingsPanelProps { export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { const { t } = useTranslation() - const activeProfileIdByGame = useProfileStore((s) => s.activeProfileIdByGame) - const profilesByGame = useProfileStore((s) => s.profilesByGame) - const resetGameProfilesToDefault = useProfileStore((s) => s.resetGameProfilesToDefault) - const removeGameProfiles = useProfileStore((s) => s.removeGameProfiles) - + const { profilesByGame, activeProfileIdByGame } = useProfileData() + const profileMut = useProfileActions() + const resetProfileMutation = trpc.profiles.resetProfile.useMutation() const unmanageGameMutation = trpc.games.unmanageGameCleanup.useMutation() const cleanupInjectedMutation = trpc.launch.cleanupInjected.useMutation() - - const { dataFolder } = useSettingsStore((s) => s.global) - const getPerGame = useSettingsStore((s) => s.getPerGame) - const updatePerGame = useSettingsStore((s) => s.updatePerGame) - const deletePerGame = useSettingsStore((s) => s.deletePerGame) - - const deleteProfileState = useModManagementStore((s) => s.deleteProfileState) - - const removeManagedGame = useGameManagementStore((s) => s.removeManagedGame) - + + const { global: globalSettings, getPerGame } = useSettingsData() + const settingsMut = useSettingsActions() + + const modMut = useModManagementActions() + const gameMut = useGameManagementActions() + const selectGame = useAppStore((s) => s.selectGame) const setSettingsOpen = useAppStore((s) => s.setSettingsOpen) @@ -71,13 +70,13 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { } const handleLaunchParametersChange = (value: string) => { - updatePerGame(gameId, { launchParameters: value }) + settingsMut.updatePerGame(gameId, { launchParameters: value }) } const handleBrowseProfileFolder = () => { const profileId = activeProfileIdByGame[gameId] if (profileId) { - const profilePath = `${dataFolder}/${gameId}/profiles/${profileId}` + const profilePath = `${globalSettings.dataFolder}/${gameId}/profiles/${profileId}` openFolder(profilePath) } } @@ -88,24 +87,21 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { ) if (confirmed) { try { - // Get all profiles for this game const profiles = profilesByGame[gameId] || [] - + let totalFilesRemoved = 0 - - // Delete BepInEx folder for each profile + for (const profile of profiles) { const result = await resetProfileMutation.mutateAsync({ gameId, profileId: profile.id, }) totalFilesRemoved += result.filesRemoved - deleteProfileState(profile.id) + await modMut.deleteProfileState(profile.id) } - - // Reset profiles to Default only - resetGameProfilesToDefault(gameId) - + + await profileMut.resetGameProfilesToDefault(gameId) + toast.success(`${game.name} installation reset`, { description: `${totalFilesRemoved} files removed, reset to Default profile`, }) @@ -124,37 +120,30 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { ) if (confirmed) { try { - // First cleanup injected files const cleanupResult = await cleanupInjectedMutation.mutateAsync({ gameId, }) - - // Then delete all game files (profiles + downloads + caches) + const result = await unmanageGameMutation.mutateAsync({ gameId, }) - - // Get all profiles for this game + const profiles = profilesByGame[gameId] || [] - - // Clear state for each profile - profiles.forEach((profile) => { - deleteProfileState(profile.id) - }) - - // Remove all game data from stores - removeGameProfiles(gameId) - deletePerGame(gameId) - const nextDefaultGameId = removeManagedGame(gameId) - - // Update selected game + + for (const profile of profiles) { + await modMut.deleteProfileState(profile.id) + } + + await profileMut.removeGameProfiles(gameId) + await settingsMut.deletePerGame(gameId) + const nextDefaultGameId = await gameMut.removeManagedGame(gameId) + selectGame(nextDefaultGameId) - - // Close settings if no games remain + if (!nextDefaultGameId) { setSettingsOpen(false) } - + toast.success(`${game.name} removed`, { description: `Cleaned up ${cleanupResult.restored + cleanupResult.removed} injected files, ${result.totalRemoved} total files removed`, }) @@ -166,7 +155,7 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { } } } - + const handleCleanupInjected = async () => { const confirmed = confirm( `Clean up injected files from ${game.name}?\n\nThis will:\n- Remove BepInEx/Doorstop files from the game install folder\n- Restore backed-up original files\n- Skip files that were modified after injection\n\nThe game must be closed before proceeding.` @@ -176,7 +165,7 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { const result = await cleanupInjectedMutation.mutateAsync({ gameId, }) - + toast.success("Injected files cleaned up", { description: `Restored ${result.restored} files, removed ${result.removed} files, skipped ${result.skipped} modified files`, }) @@ -206,7 +195,7 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { updatePerGame(gameId, { gameInstallFolder: nextPath })} + onChangePath={(nextPath) => settingsMut.updatePerGame(gameId, { gameInstallFolder: nextPath })} className="w-full" /> } @@ -219,7 +208,7 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { updatePerGame(gameId, { modDownloadFolder: nextPath })} + onChangePath={(nextPath) => settingsMut.updatePerGame(gameId, { modDownloadFolder: nextPath })} className="w-full" /> } @@ -232,7 +221,7 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { updatePerGame(gameId, { modCacheFolder: nextPath })} + onChangePath={(nextPath) => settingsMut.updatePerGame(gameId, { modCacheFolder: nextPath })} className="w-full" /> } @@ -291,7 +280,7 @@ export function GameSettingsPanel({ gameId }: GameSettingsPanelProps) { } /> - + s.global.dataFolder) - const steamFolder = useSettingsStore((s) => s.global.steamFolder) - const modDownloadFolder = useSettingsStore((s) => s.global.modDownloadFolder) - const cacheFolder = useSettingsStore((s) => s.global.cacheFolder) - const updateGlobal = useSettingsStore((s) => s.updateGlobal) + const { dataFolder, steamFolder, modDownloadFolder, cacheFolder } = useSettingsData().global + const { updateGlobal } = useSettingsActions() return (
diff --git a/src/components/features/settings/panels/other-panel.tsx b/src/components/features/settings/panels/other-panel.tsx index 57c4707..f499ac2 100644 --- a/src/components/features/settings/panels/other-panel.tsx +++ b/src/components/features/settings/panels/other-panel.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react" import { SettingsRow } from "../settings-row" import { Button } from "@/components/ui/button" import { Switch } from "@/components/ui/switch" -import { useSettingsStore } from "@/store/settings-store" +import { useSettingsData, useSettingsActions } from "@/data" import { useTranslation } from "react-i18next" import { logger } from "@/lib/logger" import { @@ -26,8 +26,8 @@ export function OtherPanel(_props: PanelProps) { void _props const { t, i18n } = useTranslation() - const { theme, language, cardDisplayType, funkyMode, enforceDependencyVersions } = useSettingsStore((s) => s.global) - const updateGlobal = useSettingsStore((s) => s.updateGlobal) + const { theme, language, cardDisplayType, funkyMode, enforceDependencyVersions } = useSettingsData().global + const { updateGlobal } = useSettingsActions() const availableLanguages = useMemo( () => Object.keys(i18n.options.resources ?? {}) as string[], diff --git a/src/components/features/settings/settings-dialog.tsx b/src/components/features/settings/settings-dialog.tsx index 4e39c42..7d128c9 100644 --- a/src/components/features/settings/settings-dialog.tsx +++ b/src/components/features/settings/settings-dialog.tsx @@ -1,7 +1,7 @@ import { useState, useEffect } from "react" import { useTranslation } from "react-i18next" import { useAppStore } from "@/store/app-store" -import { useGameManagementStore } from "@/store/game-management-store" +import { useGameManagementData } from "@/data" import { Dialog, DialogContent, DialogClose } from "@/components/ui/dialog" import { XIcon, PlusIcon } from "lucide-react" import { LocationsPanel } from "./panels/locations-panel" @@ -44,7 +44,7 @@ export function SettingsDialog() { const settingsOpen = useAppStore((s) => s.settingsOpen) const setSettingsOpen = useAppStore((s) => s.setSettingsOpen) const settingsActiveSection = useAppStore((s) => s.settingsActiveSection) - const managedGameIds = useGameManagementStore((s) => s.managedGameIds) + const { managedGameIds } = useGameManagementData() const [activeSection, setActiveSection] = useState(settingsActiveSection || "other") const [searchQuery] = useState("") const [addGameOpen, setAddGameOpen] = useState(false) diff --git a/src/components/layout/global-rail.tsx b/src/components/layout/global-rail.tsx index 6519f06..05d255d 100644 --- a/src/components/layout/global-rail.tsx +++ b/src/components/layout/global-rail.tsx @@ -4,8 +4,11 @@ import { Home, Globe, Settings as SettingsIcon, Download, User, ChevronDown, Plu import { Link, useRouterState } from "@tanstack/react-router" import { useAppStore } from "@/store/app-store" -import { useProfileStore } from "@/store/profile-store" -import { useGameManagementStore } from "@/store/game-management-store" +import { + useProfileData, + useGameManagementData, + useGameManagementActions, +} from "@/data" import { ECOSYSTEM_GAMES } from "@/lib/ecosystem-games" import { Button } from "@/components/ui/button" import { @@ -37,21 +40,19 @@ export function GlobalRailContent({ onNavigate }: GlobalRailContentProps) { const settingsOpen = useAppStore((s) => s.settingsOpen) const setModLibraryTab = useAppStore((s) => s.setModLibraryTab) const pathname = useRouterState({ select: (s) => s.location.pathname }) - const activeProfileId = useProfileStore((s) => - selectedGameId ? s.activeProfileIdByGame[selectedGameId] ?? null : null - ) - const profilesByGame = useProfileStore((s) => s.profilesByGame) - const recentManagedGameIds = useGameManagementStore((s) => s.recentManagedGameIds) - const defaultGameId = useGameManagementStore((s) => s.defaultGameId) - const managedGameIds = useGameManagementStore((s) => s.managedGameIds) - const setDefaultGameId = useGameManagementStore((s) => s.setDefaultGameId) - + + const { activeProfileIdByGame, profilesByGame } = useProfileData() + const { recentManagedGameIds, defaultGameId, managedGameIds } = useGameManagementData() + const { setDefaultGameId } = useGameManagementActions() + + const activeProfileId = selectedGameId ? activeProfileIdByGame[selectedGameId] ?? null : null + // Get active profile name - const activeProfile = selectedGameId && activeProfileId + const activeProfile = selectedGameId && activeProfileId ? profilesByGame[selectedGameId]?.find(p => p.id === activeProfileId) : null const activeProfileName = activeProfile?.name ?? t("rail_no_profile") - + // Force open Add Game dialog on first run or after all games are removed useEffect(() => { const noGames = defaultGameId === null && managedGameIds.length === 0 @@ -60,14 +61,14 @@ export function GlobalRailContent({ onNavigate }: GlobalRailContentProps) { setAddGameOpen(true) } }, [defaultGameId, managedGameIds.length, settingsOpen]) - + const selectedGame = selectedGameId ? ECOSYSTEM_GAMES.find((g) => g.id === selectedGameId) : null - + // Managed games for the dropdown const managedGames = managedGameIds .map((id) => ECOSYSTEM_GAMES.find((g) => g.id === id)) .filter((g): g is typeof ECOSYSTEM_GAMES[number] => g !== undefined) - + // Recently managed games (newest first, max 3, filtered to managed only) const recentGames = recentManagedGameIds .filter((id) => managedGameIds.includes(id)) @@ -79,8 +80,8 @@ export function GlobalRailContent({ onNavigate }: GlobalRailContentProps) { return ( <> - @@ -112,8 +113,8 @@ export function GlobalRailContent({ onNavigate }: GlobalRailContentProps) {
- @@ -135,9 +136,9 @@ export function GlobalRailContent({ onNavigate }: GlobalRailContentProps) { - + - + {/* Games List Section - Scrollable */}
@@ -157,9 +158,9 @@ export function GlobalRailContent({ onNavigate }: GlobalRailContentProps) { }} > {managedGames.map((game) => ( - )} - + - + {/* Add Game Section */} - { setMenuOpen(false) diff --git a/src/data/hooks.ts b/src/data/hooks.ts index 3234662..dc5c738 100644 --- a/src/data/hooks.ts +++ b/src/data/hooks.ts @@ -1,305 +1,387 @@ /** * React hooks – the stable API that components import. * - * Reads: currently backed by Zustand selectors for zero-latency reactivity. - * Writes: always go through the async service interface. + * Data hooks: useSuspenseQuery → return type is always T (never undefined). + * This matches the Zustand selector return shapes exactly, + * so components only need to swap the hook call. + * Action hooks: call async service functions directly. + * DataBridge handles cache invalidation when Zustand changes. * - * When we migrate to DB, reads switch to React Query (useQuery) and writes - * switch to useMutation. Component call-sites stay the same. + * Pattern for component migration: + * Before: const x = useProfileStore((s) => s.profilesByGame) + * After: const { profilesByGame } = useProfileData() */ -import { useMemo, useCallback } from "react" +import { useEffect, useMemo, useCallback } from "react" +import { useSuspenseQuery, useQueryClient } from "@tanstack/react-query" import { useGameManagementStore } from "@/store/game-management-store" import { useSettingsStore } from "@/store/settings-store" import { useProfileStore } from "@/store/profile-store" +import type { Profile } from "@/store/profile-store" import { useModManagementStore } from "@/store/mod-management-store" -import { gameService, settingsService, profileService, modService } from "./index" -import type { - ManagedGame, - InstalledMod, - GlobalSettings, - GameSettings, - EffectiveGameSettings, - Profile, -} from "./interfaces" +import { + gameService, + settingsService, + profileService, + modService, +} from "./services" +import type { GlobalSettings, GameSettings } from "./interfaces" // --------------------------------------------------------------------------- -// Game hooks +// Query keys // --------------------------------------------------------------------------- -export function useGames(): { data: ManagedGame[] } { - const managedGameIds = useGameManagementStore((s) => s.managedGameIds) - const defaultGameId = useGameManagementStore((s) => s.defaultGameId) - - const data = useMemo( - () => - managedGameIds.map( - (id): ManagedGame => ({ - id, - isDefault: id === defaultGameId, - lastAccessedAt: null, - }), - ), - [managedGameIds, defaultGameId], - ) - - return { data } +export const dataKeys = { + gameManagement: ["store", "gameManagement"] as const, + settings: ["store", "settings"] as const, + profiles: ["store", "profiles"] as const, + modManagement: ["store", "modManagement"] as const, } -export function useDefaultGame(): { data: ManagedGame | null } { - const defaultGameId = useGameManagementStore((s) => s.defaultGameId) +// --------------------------------------------------------------------------- +// DataBridge – subscribe to Zustand stores and invalidate React Query cache +// --------------------------------------------------------------------------- - const data = useMemo((): ManagedGame | null => { - if (!defaultGameId) return null - return { id: defaultGameId, isDefault: true, lastAccessedAt: null } - }, [defaultGameId]) +export function DataBridge() { + const queryClient = useQueryClient() - return { data } + useEffect(() => { + const unsubs = [ + useGameManagementStore.subscribe(() => { + queryClient.invalidateQueries({ queryKey: dataKeys.gameManagement }) + }), + useSettingsStore.subscribe(() => { + queryClient.invalidateQueries({ queryKey: dataKeys.settings }) + }), + useProfileStore.subscribe(() => { + queryClient.invalidateQueries({ queryKey: dataKeys.profiles }) + }), + useModManagementStore.subscribe(() => { + queryClient.invalidateQueries({ queryKey: dataKeys.modManagement }) + }), + ] + return () => unsubs.forEach((fn) => fn()) + }, [queryClient]) + + return null } -export function useRecentGames(limit = 10): { data: ManagedGame[] } { - const recentIds = useGameManagementStore((s) => s.recentManagedGameIds) - const defaultGameId = useGameManagementStore((s) => s.defaultGameId) - - const data = useMemo( - () => - recentIds - .slice(-limit) - .reverse() - .map( - (id): ManagedGame => ({ - id, - isDefault: id === defaultGameId, - lastAccessedAt: null, - }), - ), - [recentIds, defaultGameId, limit], - ) +// =========================================================================== +// Game Management +// =========================================================================== - return { data } +type GameManagementData = { + managedGameIds: string[] + recentManagedGameIds: string[] + defaultGameId: string | null } -export function useGameMutations() { +export function useGameManagementData(): GameManagementData { + const { data } = useSuspenseQuery({ + queryKey: dataKeys.gameManagement, + queryFn: async (): Promise => { + const s = useGameManagementStore.getState() + return { + managedGameIds: s.managedGameIds, + recentManagedGameIds: s.recentManagedGameIds, + defaultGameId: s.defaultGameId, + } + }, + initialData: (): GameManagementData => { + const s = useGameManagementStore.getState() + return { + managedGameIds: s.managedGameIds, + recentManagedGameIds: s.recentManagedGameIds, + defaultGameId: s.defaultGameId, + } + }, + staleTime: Infinity, + }) + return data +} + +export function useGameManagementActions() { return useMemo( () => ({ - add: (gameId: string) => gameService.add(gameId), - remove: (gameId: string) => gameService.remove(gameId), - setDefault: (gameId: string | null) => gameService.setDefault(gameId), - touch: (gameId: string) => gameService.touch(gameId), + addManagedGame: (gameId: string) => gameService.add(gameId), + removeManagedGame: (gameId: string) => gameService.remove(gameId), + setDefaultGameId: (gameId: string | null) => + gameService.setDefault(gameId), + appendRecentManagedGame: (gameId: string) => gameService.touch(gameId), }), - [], + [] ) } -// --------------------------------------------------------------------------- -// Settings hooks -// --------------------------------------------------------------------------- +// =========================================================================== +// Settings +// =========================================================================== -export function useGlobalSettings(): { data: GlobalSettings } { - const global = useSettingsStore((s) => s.global) - return { data: global } +type SettingsData = { + global: GlobalSettings + perGame: Record + /** Derived helper matching store's getPerGame(). */ + getPerGame: (gameId: string) => GameSettings } -export function useGameSettings(gameId: string | null): { data: GameSettings | null } { - const perGame = useSettingsStore((s) => s.perGame) +const defaultGameSettings: GameSettings = { + gameInstallFolder: "", + modDownloadFolder: "", + cacheFolder: "", + modCacheFolder: "", + launchParameters: "", + onlineModListCacheDate: null, +} - const data = useMemo((): GameSettings | null => { - if (!gameId) return null - return useSettingsStore.getState().getPerGame(gameId) - }, [gameId, perGame]) +export function useSettingsData(): SettingsData { + const { data } = useSuspenseQuery({ + queryKey: dataKeys.settings, + queryFn: async () => { + const s = useSettingsStore.getState() + return { + global: { ...s.global }, + perGame: { ...s.perGame } as Record, + } + }, + initialData: () => { + const s = useSettingsStore.getState() + return { + global: { ...s.global }, + perGame: { ...s.perGame } as Record, + } + }, + staleTime: Infinity, + }) - return { data } -} + const getPerGame = useCallback( + (gameId: string): GameSettings => ({ + ...defaultGameSettings, + ...data.perGame[gameId], + }), + [data.perGame] + ) -export function useEffectiveGameSettings( - gameId: string | null, -): { data: EffectiveGameSettings | null } { - const global = useSettingsStore((s) => s.global) - const perGame = useSettingsStore((s) => s.perGame) - - const data = useMemo((): EffectiveGameSettings | null => { - if (!gameId) return null - const pg = useSettingsStore.getState().getPerGame(gameId) - return { - ...pg, - modDownloadFolder: pg.modDownloadFolder || global.modDownloadFolder, - cacheFolder: pg.cacheFolder || global.cacheFolder, - } - }, [gameId, global, perGame]) - - return { data } + return { global: data.global, perGame: data.perGame, getPerGame } } -export function useSettingsMutations() { +export function useSettingsActions() { return useMemo( () => ({ updateGlobal: (updates: Partial) => settingsService.updateGlobal(updates), - updateForGame: (gameId: string, updates: Partial) => + updatePerGame: (gameId: string, updates: Partial) => settingsService.updateForGame(gameId, updates), - resetForGame: (gameId: string) => settingsService.resetForGame(gameId), - deleteForGame: (gameId: string) => settingsService.deleteForGame(gameId), + resetPerGame: (gameId: string) => settingsService.resetForGame(gameId), + deletePerGame: (gameId: string) => + settingsService.deleteForGame(gameId), }), - [], + [] ) } -// --------------------------------------------------------------------------- -// Profile hooks -// --------------------------------------------------------------------------- +// =========================================================================== +// Profiles +// =========================================================================== -export function useProfiles(gameId: string | null): { data: Profile[] } { - const profilesByGame = useProfileStore((s) => s.profilesByGame) - - const data = useMemo( - () => (gameId ? profilesByGame[gameId] ?? [] : []), - [gameId, profilesByGame], - ) - - return { data } +type ProfileData = { + profilesByGame: Record + activeProfileIdByGame: Record } -export function useActiveProfile(gameId: string | null): { data: Profile | null } { - const profilesByGame = useProfileStore((s) => s.profilesByGame) - const activeMap = useProfileStore((s) => s.activeProfileIdByGame) - - const data = useMemo((): Profile | null => { - if (!gameId) return null - const activeId = activeMap[gameId] - if (!activeId) return null - const profiles = profilesByGame[gameId] ?? [] - return profiles.find((p) => p.id === activeId) ?? null - }, [gameId, profilesByGame, activeMap]) - - return { data } +export function useProfileData(): ProfileData { + const { data } = useSuspenseQuery({ + queryKey: dataKeys.profiles, + queryFn: async (): Promise => { + const s = useProfileStore.getState() + return { + profilesByGame: s.profilesByGame, + activeProfileIdByGame: s.activeProfileIdByGame, + } + }, + initialData: (): ProfileData => { + const s = useProfileStore.getState() + return { + profilesByGame: s.profilesByGame, + activeProfileIdByGame: s.activeProfileIdByGame, + } + }, + staleTime: Infinity, + }) + return data } -export function useProfileMutations() { +export function useProfileActions() { return useMemo( () => ({ - ensureDefault: (gameId: string) => profileService.ensureDefault(gameId), - create: (gameId: string, name: string) => profileService.create(gameId, name), - rename: (gameId: string, profileId: string, newName: string) => - profileService.rename(gameId, profileId, newName), - remove: (gameId: string, profileId: string) => - profileService.remove(gameId, profileId), - setActive: (gameId: string, profileId: string) => + ensureDefaultProfile: (gameId: string) => + profileService.ensureDefault(gameId), + setActiveProfile: (gameId: string, profileId: string) => profileService.setActive(gameId, profileId), - reset: (gameId: string) => profileService.reset(gameId), - removeAll: (gameId: string) => profileService.removeAll(gameId), + createProfile: (gameId: string, name: string) => + profileService.create(gameId, name), + renameProfile: ( + gameId: string, + profileId: string, + newName: string + ) => profileService.rename(gameId, profileId, newName), + deleteProfile: (gameId: string, profileId: string) => + profileService.remove(gameId, profileId), + resetGameProfilesToDefault: (gameId: string) => + profileService.reset(gameId), + removeGameProfiles: (gameId: string) => + profileService.removeAll(gameId), }), - [], + [] ) } -// --------------------------------------------------------------------------- -// Mod hooks -// --------------------------------------------------------------------------- +// =========================================================================== +// Mod Management +// =========================================================================== + +type ModManagementData = { + installedModsByProfile: Record> + enabledModsByProfile: Record> + installedModVersionsByProfile: Record> + dependencyWarningsByProfile: Record> + uninstallingMods: Set + // Derived helpers (matching store method signatures) + isModInstalled: (profileId: string, modId: string) => boolean + isModEnabled: (profileId: string, modId: string) => boolean + getInstalledModIds: (profileId: string) => string[] + getInstalledVersion: ( + profileId: string, + modId: string + ) => string | undefined + getDependencyWarnings: (profileId: string, modId: string) => string[] +} -export function useInstalledMods(profileId: string | null): { data: InstalledMod[] } { - const installedByProfile = useModManagementStore((s) => s.installedModsByProfile) - const enabledByProfile = useModManagementStore((s) => s.enabledModsByProfile) - const versionsByProfile = useModManagementStore((s) => s.installedModVersionsByProfile) - const warningsByProfile = useModManagementStore((s) => s.dependencyWarningsByProfile) - - const data = useMemo((): InstalledMod[] => { - if (!profileId) return [] - const installed = installedByProfile[profileId] - if (!installed) return [] - - const enabled = enabledByProfile[profileId] - const versions = versionsByProfile[profileId] ?? {} - const warnings = warningsByProfile[profileId] ?? {} - - return Array.from(installed).map( - (modId): InstalledMod => ({ - modId, - installedVersion: versions[modId] ?? "", - enabled: enabled ? enabled.has(modId) : false, - dependencyWarnings: warnings[modId] ?? [], - }), - ) - }, [profileId, installedByProfile, enabledByProfile, versionsByProfile, warningsByProfile]) +export function useModManagementData(): ModManagementData { + const { data } = useSuspenseQuery({ + queryKey: dataKeys.modManagement, + queryFn: async () => { + const s = useModManagementStore.getState() + return { + installedModsByProfile: s.installedModsByProfile, + enabledModsByProfile: s.enabledModsByProfile, + installedModVersionsByProfile: s.installedModVersionsByProfile, + dependencyWarningsByProfile: s.dependencyWarningsByProfile, + uninstallingMods: s.uninstallingMods, + } + }, + initialData: () => { + const s = useModManagementStore.getState() + return { + installedModsByProfile: s.installedModsByProfile, + enabledModsByProfile: s.enabledModsByProfile, + installedModVersionsByProfile: s.installedModVersionsByProfile, + dependencyWarningsByProfile: s.dependencyWarningsByProfile, + uninstallingMods: s.uninstallingMods, + } + }, + staleTime: Infinity, + structuralSharing: false, // Sets don't survive structural sharing + }) + + // Derived helpers matching store methods + const isModInstalled = useCallback( + (profileId: string, modId: string) => { + const set = data.installedModsByProfile[profileId] + return set ? set.has(modId) : false + }, + [data.installedModsByProfile] + ) - return { data } -} + const isModEnabled = useCallback( + (profileId: string, modId: string) => { + const set = data.enabledModsByProfile[profileId] + return set ? set.has(modId) : false + }, + [data.enabledModsByProfile] + ) -export function useIsModInstalled( - profileId: string | null, - modId: string, -): boolean { - const installedByProfile = useModManagementStore((s) => s.installedModsByProfile) - - return useMemo(() => { - if (!profileId) return false - const set = installedByProfile[profileId] - return set ? set.has(modId) : false - }, [profileId, modId, installedByProfile]) -} + const getInstalledModIds = useCallback( + (profileId: string) => { + const set = data.installedModsByProfile[profileId] + return set ? Array.from(set) : [] + }, + [data.installedModsByProfile] + ) + + const getInstalledVersion = useCallback( + (profileId: string, modId: string) => { + const map = data.installedModVersionsByProfile[profileId] + return map ? map[modId] : undefined + }, + [data.installedModVersionsByProfile] + ) + + const getDependencyWarnings = useCallback( + (profileId: string, modId: string) => { + const map = data.dependencyWarningsByProfile[profileId] + return map ? map[modId] || [] : [] + }, + [data.dependencyWarningsByProfile] + ) -export function useIsModEnabled( - profileId: string | null, - modId: string, -): boolean { - const enabledByProfile = useModManagementStore((s) => s.enabledModsByProfile) - - return useMemo(() => { - if (!profileId) return false - const set = enabledByProfile[profileId] - return set ? set.has(modId) : false - }, [profileId, modId, enabledByProfile]) + return { + ...data, + isModInstalled, + isModEnabled, + getInstalledModIds, + getInstalledVersion, + getDependencyWarnings, + } } -export function useModMutations() { +export function useModManagementActions() { return useMemo( () => ({ - install: (profileId: string, modId: string, version: string) => + installMod: (profileId: string, modId: string, version: string) => modService.install(profileId, modId, version), - uninstall: (profileId: string, modId: string) => + uninstallMod: (profileId: string, modId: string) => modService.uninstall(profileId, modId), - uninstallAll: (profileId: string) => modService.uninstallAll(profileId), - enable: (profileId: string, modId: string) => + uninstallAllMods: (profileId: string) => + modService.uninstallAll(profileId), + enableMod: (profileId: string, modId: string) => modService.enable(profileId, modId), - disable: (profileId: string, modId: string) => + disableMod: (profileId: string, modId: string) => modService.disable(profileId, modId), - toggle: (profileId: string, modId: string) => + toggleMod: (profileId: string, modId: string) => modService.toggle(profileId, modId), - setDependencyWarnings: (profileId: string, modId: string, warnings: string[]) => - modService.setDependencyWarnings(profileId, modId, warnings), + setDependencyWarnings: ( + profileId: string, + modId: string, + warnings: string[] + ) => modService.setDependencyWarnings(profileId, modId, warnings), clearDependencyWarnings: (profileId: string, modId: string) => modService.clearDependencyWarnings(profileId, modId), deleteProfileState: (profileId: string) => modService.deleteProfileState(profileId), }), - [], + [] ) } -// --------------------------------------------------------------------------- -// Convenience: combined mutation hook for common "unmanage game" flow -// --------------------------------------------------------------------------- +// =========================================================================== +// Convenience: combined mutation for "unmanage game" flow +// =========================================================================== export function useUnmanageGame() { - const gameMut = useGameMutations() - const settingsMut = useSettingsMutations() - const profileMut = useProfileMutations() - const modMut = useModMutations() + const gameMut = useGameManagementActions() + const settingsMut = useSettingsActions() + const profileMut = useProfileActions() + const modMut = useModManagementActions() return useCallback( async (gameId: string) => { - // 1. Remove all profiles' mod state - const profiles = useProfileStore.getState().profilesByGame[gameId] ?? [] + const profiles = + useProfileStore.getState().profilesByGame[gameId] ?? [] await Promise.all(profiles.map((p) => modMut.deleteProfileState(p.id))) - - // 2. Remove profiles - await profileMut.removeAll(gameId) - - // 3. Remove per-game settings - await settingsMut.deleteForGame(gameId) - - // 4. Remove game itself (returns next default game id) - return gameMut.remove(gameId) + await profileMut.removeGameProfiles(gameId) + await settingsMut.deletePerGame(gameId) + return gameMut.removeManagedGame(gameId) }, - [gameMut, settingsMut, profileMut, modMut], + [gameMut, settingsMut, profileMut, modMut] ) } diff --git a/src/data/index.ts b/src/data/index.ts index e7b65a2..64fe7d1 100644 --- a/src/data/index.ts +++ b/src/data/index.ts @@ -1,24 +1,17 @@ /** * Data layer entry point. * - * Swap the implementation here when migrating from Zustand to DB/tRPC: - * - * - import { createTrpcGameService, ... } from "./trpc" - * + export const gameService = createTrpcGameService(trpcClient) + * Swap the service implementations in services.ts when migrating from + * Zustand to DB/tRPC. */ -import { - createZustandGameService, - createZustandSettingsService, - createZustandProfileService, - createZustandModService, -} from "./zustand" - -// Service singletons – one swap point for the entire app -export const gameService = createZustandGameService() -export const settingsService = createZustandSettingsService() -export const profileService = createZustandProfileService() -export const modService = createZustandModService() +// Re-export service singletons (for imperative access in non-hook code) +export { + gameService, + settingsService, + profileService, + modService, +} from "./services" // Re-export types & interfaces for convenience export type { @@ -34,27 +27,23 @@ export type { IModService, } from "./interfaces" -// Re-export hooks +// Re-export hooks + DataBridge export { - // Game - useGames, - useDefaultGame, - useRecentGames, - useGameMutations, + // Bridge (mount once near app root) + DataBridge, + dataKeys, + // Game Management + useGameManagementData, + useGameManagementActions, // Settings - useGlobalSettings, - useGameSettings, - useEffectiveGameSettings, - useSettingsMutations, - // Profile - useProfiles, - useActiveProfile, - useProfileMutations, - // Mod - useInstalledMods, - useIsModInstalled, - useIsModEnabled, - useModMutations, + useSettingsData, + useSettingsActions, + // Profiles + useProfileData, + useProfileActions, + // Mod Management + useModManagementData, + useModManagementActions, // Compound useUnmanageGame, } from "./hooks" diff --git a/src/data/interfaces.ts b/src/data/interfaces.ts index 5f4b2ab..838c882 100644 --- a/src/data/interfaces.ts +++ b/src/data/interfaces.ts @@ -54,7 +54,7 @@ export type GlobalSettings = { } export type GameSettings = { - installFolder: string + gameInstallFolder: string modDownloadFolder: string cacheFolder: string modCacheFolder: string diff --git a/src/data/services.ts b/src/data/services.ts new file mode 100644 index 0000000..a717790 --- /dev/null +++ b/src/data/services.ts @@ -0,0 +1,18 @@ +/** + * Service singletons – separated from index.ts to break circular dependency + * with hooks.ts. + * + * Swap the implementation here when migrating from Zustand to DB/tRPC. + */ + +import { + createZustandGameService, + createZustandSettingsService, + createZustandProfileService, + createZustandModService, +} from "./zustand" + +export const gameService = createZustandGameService() +export const settingsService = createZustandSettingsService() +export const profileService = createZustandProfileService() +export const modService = createZustandModService() diff --git a/src/hooks/use-mod-actions.ts b/src/hooks/use-mod-actions.ts index d6deb37..33800bd 100644 --- a/src/hooks/use-mod-actions.ts +++ b/src/hooks/use-mod-actions.ts @@ -5,12 +5,12 @@ import { useCallback } from "react" import { toast } from "sonner" import { trpc } from "@/lib/trpc" -import { useModManagementStore } from "@/store/mod-management-store" +import { useModManagementActions } from "@/data" import { useAppStore } from "@/store/app-store" export function useModActions() { const uninstallModMutation = trpc.profiles.uninstallMod.useMutation() - const markUninstalled = useModManagementStore((s) => s.uninstallMod) + const { uninstallMod: markUninstalled } = useModManagementActions() const selectedGameId = useAppStore((s) => s.selectedGameId) /** diff --git a/src/hooks/use-mod-installer.ts b/src/hooks/use-mod-installer.ts index 65585c8..01af61a 100644 --- a/src/hooks/use-mod-installer.ts +++ b/src/hooks/use-mod-installer.ts @@ -7,14 +7,13 @@ import { toast } from "sonner" import { trpc } from "@/lib/trpc" import { useDownloadStore } from "@/store/download-store" import { useDownloadActions } from "./use-download-actions" -import { useModManagementStore } from "@/store/mod-management-store" +import { useModManagementActions } from "@/data" export function useModInstaller() { const { startDownload } = useDownloadActions() const installModMutation = trpc.profiles.installMod.useMutation() const uninstallModMutation = trpc.profiles.uninstallMod.useMutation() - const markInstalled = useModManagementStore((s) => s.installMod) - const markUninstalled = useModManagementStore((s) => s.uninstallMod) + const { installMod: markInstalled, uninstallMod: markUninstalled } = useModManagementActions() /** * Installs a downloaded mod to a profile diff --git a/src/main.tsx b/src/main.tsx index 48027c3..daed72c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,6 +7,7 @@ import "./index.css" import "./lib/i18n" import { createRouter } from "./router" import { AppBootstrap } from "./components/app-bootstrap" +import { DataBridge } from "./data" import { queryClient } from "./lib/query-client" import { TRPCProvider } from "./lib/trpc" @@ -34,6 +35,7 @@ createRoot(rootEl).render( + {import.meta.env.DEV && ( From 980a3f6fa11df6541a3793d208fd41b971b1372b Mon Sep 17 00:00:00 2001 From: akarachen Date: Sat, 7 Feb 2026 11:26:20 +0800 Subject: [PATCH 3/6] init: db --- drizzle.config.ts | 7 + electron/db/index.ts | 35 ++ electron/db/migrate.ts | 11 + .../db/migrations/0000_perpetual_mentallo.sql | 64 +++ .../db/migrations/meta/0000_snapshot.json | 459 ++++++++++++++++++ electron/db/migrations/meta/_journal.json | 13 + electron/db/schema.ts | 80 +++ electron/main.ts | 9 + package.json | 36 +- pnpm-lock.yaml | 379 +++++++++++++++ 10 files changed, 1088 insertions(+), 5 deletions(-) create mode 100644 drizzle.config.ts create mode 100644 electron/db/index.ts create mode 100644 electron/db/migrate.ts create mode 100644 electron/db/migrations/0000_perpetual_mentallo.sql create mode 100644 electron/db/migrations/meta/0000_snapshot.json create mode 100644 electron/db/migrations/meta/_journal.json create mode 100644 electron/db/schema.ts diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..e97f052 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "drizzle-kit" + +export default defineConfig({ + schema: "./electron/db/schema.ts", + out: "./electron/db/migrations", + dialect: "sqlite", +}) diff --git a/electron/db/index.ts b/electron/db/index.ts new file mode 100644 index 0000000..466961b --- /dev/null +++ b/electron/db/index.ts @@ -0,0 +1,35 @@ +import Database from "better-sqlite3" +import { drizzle } from "drizzle-orm/better-sqlite3" +import { app } from "electron" +import { join } from "path" +import * as schema from "./schema" + +type AppDb = ReturnType> + +let db: AppDb | null = null +let sqlite: Database.Database | null = null + +export function getDb(): AppDb { + if (!db) { + throw new Error("Database not initialized. Call initializeDb() first.") + } + return db +} + +export function initializeDb(): AppDb { + const dbPath = join(app.getPath("userData"), "r2modman.db") + sqlite = new Database(dbPath) + sqlite.pragma("journal_mode = WAL") + sqlite.pragma("foreign_keys = ON") + + db = drizzle({ client: sqlite, schema }) + return db +} + +export function closeDb(): void { + if (sqlite) { + sqlite.close() + sqlite = null + db = null + } +} diff --git a/electron/db/migrate.ts b/electron/db/migrate.ts new file mode 100644 index 0000000..41338d6 --- /dev/null +++ b/electron/db/migrate.ts @@ -0,0 +1,11 @@ +import { migrate } from "drizzle-orm/better-sqlite3/migrator" +import { getDb } from "./index" +import { join } from "path" + +export function runMigrations(): void { + const db = getDb() + // In development, migrations are relative to project root. + // In production, they need to be bundled — adjust path as needed. + const migrationsFolder = join(__dirname, "../../electron/db/migrations") + migrate(db, { migrationsFolder }) +} diff --git a/electron/db/migrations/0000_perpetual_mentallo.sql b/electron/db/migrations/0000_perpetual_mentallo.sql new file mode 100644 index 0000000..136caec --- /dev/null +++ b/electron/db/migrations/0000_perpetual_mentallo.sql @@ -0,0 +1,64 @@ +CREATE TABLE `game` ( + `id` text PRIMARY KEY NOT NULL, + `is_default` integer DEFAULT false NOT NULL, + `last_accessed_at` text, + `created_at` text NOT NULL +); +--> statement-breakpoint +CREATE TABLE `game_settings` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `game_id` text NOT NULL, + `mod_download_folder` text, + `cache_folder` text, + `game_install_folder` text DEFAULT '' NOT NULL, + `mod_cache_folder` text DEFAULT '' NOT NULL, + `launch_parameters` text DEFAULT '' NOT NULL, + `online_mod_list_cache_date` text, + FOREIGN KEY (`game_id`) REFERENCES `game`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `game_settings_game_id_unique` ON `game_settings` (`game_id`);--> statement-breakpoint +CREATE TABLE `global_settings` ( + `id` integer PRIMARY KEY NOT NULL, + `data_folder` text DEFAULT '' NOT NULL, + `steam_folder` text DEFAULT '' NOT NULL, + `mod_download_folder` text DEFAULT '' NOT NULL, + `cache_folder` text DEFAULT '' NOT NULL, + `speed_limit_enabled` integer DEFAULT false NOT NULL, + `speed_limit_bps` integer DEFAULT 0 NOT NULL, + `speed_unit` text DEFAULT 'Bps' NOT NULL, + `max_concurrent_downloads` integer DEFAULT 3 NOT NULL, + `download_cache_enabled` integer DEFAULT true NOT NULL, + `preferred_thunderstore_cdn` text DEFAULT 'main' NOT NULL, + `auto_install_mods` integer DEFAULT true NOT NULL, + `enforce_dependency_versions` integer DEFAULT true NOT NULL, + `card_display_type` text DEFAULT 'collapsed' NOT NULL, + `theme` text DEFAULT 'dark' NOT NULL, + `language` text DEFAULT 'en' NOT NULL, + `funky_mode` integer DEFAULT false NOT NULL +); +--> statement-breakpoint +CREATE TABLE `profile` ( + `id` text PRIMARY KEY NOT NULL, + `game_id` text NOT NULL, + `name` text NOT NULL, + `is_default` integer DEFAULT false NOT NULL, + `is_active` integer DEFAULT false NOT NULL, + `created_at` text NOT NULL, + FOREIGN KEY (`game_id`) REFERENCES `game`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `idx_profile_game_id` ON `profile` (`game_id`);--> statement-breakpoint +CREATE TABLE `profile_mod` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `profile_id` text NOT NULL, + `mod_id` text NOT NULL, + `installed_version` text NOT NULL, + `enabled` integer DEFAULT true NOT NULL, + `dependency_warnings` text, + FOREIGN KEY (`profile_id`) REFERENCES `profile`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `uq_profile_mod_profile_mod` ON `profile_mod` (`profile_id`,`mod_id`);--> statement-breakpoint +CREATE INDEX `idx_profile_mod_profile_id` ON `profile_mod` (`profile_id`);--> statement-breakpoint +CREATE INDEX `idx_profile_mod_mod_id` ON `profile_mod` (`mod_id`); \ No newline at end of file diff --git a/electron/db/migrations/meta/0000_snapshot.json b/electron/db/migrations/meta/0000_snapshot.json new file mode 100644 index 0000000..135af13 --- /dev/null +++ b/electron/db/migrations/meta/0000_snapshot.json @@ -0,0 +1,459 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b49998b3-aa60-4301-a14b-8e5dc77cccdb", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "game": { + "name": "game", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "game_settings": { + "name": "game_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mod_download_folder": { + "name": "mod_download_folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_folder": { + "name": "cache_folder", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "game_install_folder": { + "name": "game_install_folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "mod_cache_folder": { + "name": "mod_cache_folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "launch_parameters": { + "name": "launch_parameters", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "online_mod_list_cache_date": { + "name": "online_mod_list_cache_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "game_settings_game_id_unique": { + "name": "game_settings_game_id_unique", + "columns": [ + "game_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "game_settings_game_id_game_id_fk": { + "name": "game_settings_game_id_game_id_fk", + "tableFrom": "game_settings", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "global_settings": { + "name": "global_settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data_folder": { + "name": "data_folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "steam_folder": { + "name": "steam_folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "mod_download_folder": { + "name": "mod_download_folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "cache_folder": { + "name": "cache_folder", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "speed_limit_enabled": { + "name": "speed_limit_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "speed_limit_bps": { + "name": "speed_limit_bps", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "speed_unit": { + "name": "speed_unit", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Bps'" + }, + "max_concurrent_downloads": { + "name": "max_concurrent_downloads", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 3 + }, + "download_cache_enabled": { + "name": "download_cache_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "preferred_thunderstore_cdn": { + "name": "preferred_thunderstore_cdn", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'main'" + }, + "auto_install_mods": { + "name": "auto_install_mods", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enforce_dependency_versions": { + "name": "enforce_dependency_versions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "card_display_type": { + "name": "card_display_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'collapsed'" + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'dark'" + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "funky_mode": { + "name": "funky_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "profile": { + "name": "profile", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_profile_game_id": { + "name": "idx_profile_game_id", + "columns": [ + "game_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "profile_game_id_game_id_fk": { + "name": "profile_game_id_game_id_fk", + "tableFrom": "profile", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "profile_mod": { + "name": "profile_mod", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "profile_id": { + "name": "profile_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mod_id": { + "name": "mod_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "installed_version": { + "name": "installed_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "dependency_warnings": { + "name": "dependency_warnings", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_profile_mod_profile_mod": { + "name": "uq_profile_mod_profile_mod", + "columns": [ + "profile_id", + "mod_id" + ], + "isUnique": true + }, + "idx_profile_mod_profile_id": { + "name": "idx_profile_mod_profile_id", + "columns": [ + "profile_id" + ], + "isUnique": false + }, + "idx_profile_mod_mod_id": { + "name": "idx_profile_mod_mod_id", + "columns": [ + "mod_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "profile_mod_profile_id_profile_id_fk": { + "name": "profile_mod_profile_id_profile_id_fk", + "tableFrom": "profile_mod", + "tableTo": "profile", + "columnsFrom": [ + "profile_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/electron/db/migrations/meta/_journal.json b/electron/db/migrations/meta/_journal.json new file mode 100644 index 0000000..70a67e0 --- /dev/null +++ b/electron/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1770434019523, + "tag": "0000_perpetual_mentallo", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/electron/db/schema.ts b/electron/db/schema.ts new file mode 100644 index 0000000..64ef600 --- /dev/null +++ b/electron/db/schema.ts @@ -0,0 +1,80 @@ +import { sqliteTable, text, integer, uniqueIndex, index } from "drizzle-orm/sqlite-core" + +// ── GlobalSettings(单例,id 固定 = 1) ────────────────────────────── +export const globalSettings = sqliteTable("global_settings", { + id: integer("id").primaryKey().$default(() => 1), + + // 路径 + dataFolder: text("data_folder").notNull().default(""), + steamFolder: text("steam_folder").notNull().default(""), + modDownloadFolder: text("mod_download_folder").notNull().default(""), + cacheFolder: text("cache_folder").notNull().default(""), + + // 下载 + speedLimitEnabled: integer("speed_limit_enabled", { mode: "boolean" }).notNull().default(false), + speedLimitBps: integer("speed_limit_bps").notNull().default(0), + speedUnit: text("speed_unit").notNull().default("Bps"), // "Bps" | "bps" + maxConcurrentDownloads: integer("max_concurrent_downloads").notNull().default(3), + downloadCacheEnabled: integer("download_cache_enabled", { mode: "boolean" }).notNull().default(true), + preferredThunderstoreCdn: text("preferred_thunderstore_cdn").notNull().default("main"), + autoInstallMods: integer("auto_install_mods", { mode: "boolean" }).notNull().default(true), + + // Mod + enforceDependencyVersions: integer("enforce_dependency_versions", { mode: "boolean" }).notNull().default(true), + + // UI + cardDisplayType: text("card_display_type").notNull().default("collapsed"), // "collapsed" | "expanded" + theme: text("theme").notNull().default("dark"), // "dark" | "light" | "system" + language: text("language").notNull().default("en"), + funkyMode: integer("funky_mode", { mode: "boolean" }).notNull().default(false), +}) + +// ── Game(用户管理的游戏) ─────────────────────────────────────────── +export const game = sqliteTable("game", { + id: text("id").primaryKey(), // Thunderstore community identifier + isDefault: integer("is_default", { mode: "boolean" }).notNull().default(false), + lastAccessedAt: text("last_accessed_at"), // ISO8601 + createdAt: text("created_at").notNull().$defaultFn(() => new Date().toISOString()), +}) + +// ── GameSettings(per-game 覆盖,nullable = 继承 global) ─────────── +export const gameSettings = sqliteTable("game_settings", { + id: integer("id").primaryKey({ autoIncrement: true }), + gameId: text("game_id").notNull().unique().references(() => game.id, { onDelete: "cascade" }), + + // 可覆盖全局的(nullable → fallback to global) + modDownloadFolder: text("mod_download_folder"), + cacheFolder: text("cache_folder"), + + // 仅 per-game + gameInstallFolder: text("game_install_folder").notNull().default(""), + modCacheFolder: text("mod_cache_folder").notNull().default(""), + launchParameters: text("launch_parameters").notNull().default(""), + onlineModListCacheDate: text("online_mod_list_cache_date"), // ISO8601 | null +}) + +// ── Profile ───────────────────────────────────────────────────────── +export const profile = sqliteTable("profile", { + id: text("id").primaryKey(), // "{gameId}-{uuid}" or "{gameId}-default" + gameId: text("game_id").notNull().references(() => game.id, { onDelete: "cascade" }), + name: text("name").notNull(), + isDefault: integer("is_default", { mode: "boolean" }).notNull().default(false), + isActive: integer("is_active", { mode: "boolean" }).notNull().default(false), + createdAt: text("created_at").notNull().$defaultFn(() => new Date().toISOString()), +}, (table) => [ + index("idx_profile_game_id").on(table.gameId), +]) + +// ── ProfileMod(per-profile 的 mod 安装记录) ──────────────────────── +export const profileMod = sqliteTable("profile_mod", { + id: integer("id").primaryKey({ autoIncrement: true }), + profileId: text("profile_id").notNull().references(() => profile.id, { onDelete: "cascade" }), + modId: text("mod_id").notNull(), // Thunderstore full_name "Author-ModName" + installedVersion: text("installed_version").notNull(), + enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), + dependencyWarnings: text("dependency_warnings"), // JSON array string | null +}, (table) => [ + uniqueIndex("uq_profile_mod_profile_mod").on(table.profileId, table.modId), + index("idx_profile_mod_profile_id").on(table.profileId), + index("idx_profile_mod_mod_id").on(table.modId), +]) diff --git a/electron/main.ts b/electron/main.ts index 4b13824..899900c 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -7,6 +7,8 @@ import { initializeDownloadManager } from "./downloads/manager" import { getPathSettings } from "./downloads/settings-state" import { closeAllCatalogs } from "./thunderstore/catalog" import { initializeLogger, destroyLogger } from "./file-logger" +import { initializeDb, closeDb } from "./db" +import { runMigrations } from "./db/migrate" // The built directory structure // @@ -74,6 +76,8 @@ function createWindow() { // Clean up resources before app quits app.on("before-quit", () => { + // Close user data DB + closeDb() // Close all SQLite catalog connections closeAllCatalogs() // Flush and close logger @@ -103,6 +107,11 @@ app.whenReady().then(() => { // Initialize file logger const logger = initializeLogger() logger.info("Application started", { version: app.getVersion(), platform: process.platform }) + + // Initialize user data database and run migrations + initializeDb() + runMigrations() + logger.info("Database initialized") // Initialize download manager with settings fetcher from shared state const downloadManager = initializeDownloadManager( diff --git a/package.json b/package.json index 30046a4..cec1469 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,9 @@ "dist:win-x64": "pnpm build:electron && electron-builder --win --x64", "dist:win-arm64": "pnpm build:electron && electron-builder --win --arm64", "dist:linux-x64": "pnpm build:electron && electron-builder --linux --x64", - "dist:linux-arm64": "pnpm build:electron && electron-builder --linux --arm64" + "dist:linux-arm64": "pnpm build:electron && electron-builder --linux --arm64", + "db:generate": "drizzle-kit generate", + "db:push": "drizzle-kit push" }, "dependencies": { "@base-ui/react": "^1.1.0", @@ -38,6 +40,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dompurify": "^3.3.1", + "drizzle-orm": "^0.45.1", "electron-trpc-experimental": "1.0.0-alpha.1", "i18next": "^25.8.0", "lucide-react": "^0.562.0", @@ -69,6 +72,7 @@ "@types/semver": "^7.7.1", "@types/yauzl": "^2.10.3", "@vitejs/plugin-react": "^5.1.1", + "drizzle-kit": "^0.31.8", "electron": "^40.0.0", "electron-builder": "^26.4.0", "electron-vite": "^5.0.0", @@ -95,19 +99,41 @@ "artifactName": "${productName}-${version}-${os}-${arch}.${ext}", "mac": { "target": [ - { "target": "dmg", "arch": ["arm64"] }, - { "target": "zip", "arch": ["arm64"] } + { + "target": "dmg", + "arch": [ + "arm64" + ] + }, + { + "target": "zip", + "arch": [ + "arm64" + ] + } ], "category": "public.app-category.utilities" }, "win": { "target": [ - { "target": "nsis", "arch": ["x64", "arm64"] } + { + "target": "nsis", + "arch": [ + "x64", + "arm64" + ] + } ] }, "linux": { "target": [ - { "target": "AppImage", "arch": ["x64", "arm64"] } + { + "target": "AppImage", + "arch": [ + "x64", + "arm64" + ] + } ], "category": "Utility" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be7ef04..f50d515 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: dompurify: specifier: ^3.3.1 version: 3.3.1 + drizzle-orm: + specifier: ^0.45.1 + version: 0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.6.2) electron-trpc-experimental: specifier: 1.0.0-alpha.1 version: 1.0.0-alpha.1(@trpc/client@11.9.0(@trpc/server@11.9.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.9.0(typescript@5.9.3))(electron@40.1.0) @@ -138,6 +141,9 @@ importers: '@vitejs/plugin-react': specifier: ^5.1.1 version: 5.1.3(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)) + drizzle-kit: + specifier: ^0.31.8 + version: 0.31.8 electron: specifier: ^40.0.0 version: 40.1.0 @@ -370,6 +376,9 @@ packages: resolution: {integrity: sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w==} hasBin: true + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + '@ecies/ciphers@0.2.5': resolution: {integrity: sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} @@ -416,6 +425,14 @@ packages: engines: {node: '>=14.14'} hasBin: true + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -428,6 +445,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + 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'} @@ -440,6 +463,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} @@ -452,6 +481,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} @@ -464,6 +499,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} @@ -476,6 +517,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} @@ -488,6 +535,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} @@ -500,6 +553,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} @@ -512,6 +571,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} @@ -524,6 +589,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} @@ -536,6 +607,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + 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'} @@ -548,6 +625,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + 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'} @@ -560,6 +643,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} @@ -572,6 +661,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} @@ -584,6 +679,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} @@ -596,6 +697,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} @@ -608,6 +715,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -632,6 +745,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -656,6 +775,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -680,6 +805,12 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} @@ -692,6 +823,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} @@ -704,6 +841,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} @@ -716,6 +859,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -2000,6 +2149,102 @@ packages: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} + drizzle-kit@0.31.8: + resolution: {integrity: sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==} + hasBin: true + + drizzle-orm@0.45.1: + resolution: {integrity: sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -2112,6 +2357,16 @@ packages: es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} + esbuild-register@3.6.0: + resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} + peerDependencies: + esbuild: '>=0.12 <1' + + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -4420,6 +4675,8 @@ snapshots: picomatch: 4.0.3 which: 4.0.0 + '@drizzle-team/brocli@0.10.2': {} + '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': dependencies: '@noble/ciphers': 1.3.0 @@ -4524,102 +4781,160 @@ snapshots: - supports-color optional: true + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.13.1 + '@esbuild/aix-ppc64@0.25.12': optional: true '@esbuild/aix-ppc64@0.27.2': optional: true + '@esbuild/android-arm64@0.18.20': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true '@esbuild/android-arm64@0.27.2': optional: true + '@esbuild/android-arm@0.18.20': + optional: true + '@esbuild/android-arm@0.25.12': optional: true '@esbuild/android-arm@0.27.2': optional: true + '@esbuild/android-x64@0.18.20': + optional: true + '@esbuild/android-x64@0.25.12': optional: true '@esbuild/android-x64@0.27.2': optional: true + '@esbuild/darwin-arm64@0.18.20': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true '@esbuild/darwin-arm64@0.27.2': optional: true + '@esbuild/darwin-x64@0.18.20': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true '@esbuild/darwin-x64@0.27.2': optional: true + '@esbuild/freebsd-arm64@0.18.20': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true '@esbuild/freebsd-arm64@0.27.2': optional: true + '@esbuild/freebsd-x64@0.18.20': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true '@esbuild/freebsd-x64@0.27.2': optional: true + '@esbuild/linux-arm64@0.18.20': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true '@esbuild/linux-arm64@0.27.2': optional: true + '@esbuild/linux-arm@0.18.20': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true '@esbuild/linux-arm@0.27.2': optional: true + '@esbuild/linux-ia32@0.18.20': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true '@esbuild/linux-ia32@0.27.2': optional: true + '@esbuild/linux-loong64@0.18.20': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true '@esbuild/linux-loong64@0.27.2': optional: true + '@esbuild/linux-mips64el@0.18.20': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true '@esbuild/linux-mips64el@0.27.2': optional: true + '@esbuild/linux-ppc64@0.18.20': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true '@esbuild/linux-ppc64@0.27.2': optional: true + '@esbuild/linux-riscv64@0.18.20': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true '@esbuild/linux-riscv64@0.27.2': optional: true + '@esbuild/linux-s390x@0.18.20': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true '@esbuild/linux-s390x@0.27.2': optional: true + '@esbuild/linux-x64@0.18.20': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true @@ -4632,6 +4947,9 @@ snapshots: '@esbuild/netbsd-arm64@0.27.2': optional: true + '@esbuild/netbsd-x64@0.18.20': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true @@ -4644,6 +4962,9 @@ snapshots: '@esbuild/openbsd-arm64@0.27.2': optional: true + '@esbuild/openbsd-x64@0.18.20': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true @@ -4656,24 +4977,36 @@ snapshots: '@esbuild/openharmony-arm64@0.27.2': optional: true + '@esbuild/sunos-x64@0.18.20': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true '@esbuild/sunos-x64@0.27.2': optional: true + '@esbuild/win32-arm64@0.18.20': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true '@esbuild/win32-arm64@0.27.2': optional: true + '@esbuild/win32-ia32@0.18.20': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true '@esbuild/win32-ia32@0.27.2': optional: true + '@esbuild/win32-x64@0.18.20': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true @@ -6001,6 +6334,20 @@ snapshots: dotenv@17.2.3: {} + drizzle-kit@0.31.8: + dependencies: + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.12 + esbuild-register: 3.6.0(esbuild@0.25.12) + transitivePeerDependencies: + - supports-color + + drizzle-orm@0.45.1(@types/better-sqlite3@7.6.13)(better-sqlite3@12.6.2): + optionalDependencies: + '@types/better-sqlite3': 7.6.13 + better-sqlite3: 12.6.2 + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6153,6 +6500,38 @@ snapshots: es6-error@4.1.1: optional: true + esbuild-register@3.6.0(esbuild@0.25.12): + dependencies: + debug: 4.4.3 + esbuild: 0.25.12 + transitivePeerDependencies: + - supports-color + + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12 From 3e301296e2176a0fe61ff2cc84c103666e3d6793 Mon Sep 17 00:00:00 2001 From: akarachen Date: Sat, 7 Feb 2026 11:30:48 +0800 Subject: [PATCH 4/6] feat: trpc design interface --- electron/trpc/data/games.ts | 67 +++++++++++++ electron/trpc/data/index.ts | 12 +++ electron/trpc/data/mods.ts | 171 ++++++++++++++++++++++++++++++++ electron/trpc/data/profiles.ts | 121 +++++++++++++++++++++++ electron/trpc/data/settings.ts | 172 +++++++++++++++++++++++++++++++++ electron/trpc/router.ts | 20 +--- electron/trpc/trpc.ts | 10 ++ 7 files changed, 556 insertions(+), 17 deletions(-) create mode 100644 electron/trpc/data/games.ts create mode 100644 electron/trpc/data/index.ts create mode 100644 electron/trpc/data/mods.ts create mode 100644 electron/trpc/data/profiles.ts create mode 100644 electron/trpc/data/settings.ts create mode 100644 electron/trpc/trpc.ts diff --git a/electron/trpc/data/games.ts b/electron/trpc/data/games.ts new file mode 100644 index 0000000..f95057e --- /dev/null +++ b/electron/trpc/data/games.ts @@ -0,0 +1,67 @@ +import { z } from "zod" +import { t, publicProcedure } from "../trpc" + +/** Convert ISO8601 string to epoch ms, or null */ +export function isoToEpoch(iso: string | null | undefined): number | null { + return iso ? new Date(iso).getTime() : null +} + +export const dataGamesRouter = t.router({ + /** Get all managed games */ + list: publicProcedure.query(async () => { + // TODO: select all from game table, map lastAccessedAt via isoToEpoch + return [] as Array<{ id: string; isDefault: boolean; lastAccessedAt: number | null }> + }), + + /** Get the default game, or null */ + getDefault: publicProcedure.query(async () => { + // TODO: select from game where isDefault = true limit 1 + return null as { id: string; isDefault: boolean; lastAccessedAt: number | null } | null + }), + + /** Get recently accessed games, ordered by lastAccessedAt desc */ + getRecent: publicProcedure + .input(z.object({ limit: z.number().int().positive().optional().default(10) })) + .query(async ({ input }) => { + // TODO: select from game where lastAccessedAt IS NOT NULL + // order by lastAccessedAt desc, limit input.limit + void input + return [] as Array<{ id: string; isDefault: boolean; lastAccessedAt: number | null }> + }), + + /** Add a game to the managed list */ + add: publicProcedure + .input(z.object({ gameId: z.string().min(1) })) + .mutation(async ({ input }) => { + // TODO: insert into game (id) on conflict do nothing + void input + }), + + /** Remove a game. Returns the removed gameId or null if not found. */ + remove: publicProcedure + .input(z.object({ gameId: z.string().min(1) })) + .mutation(async ({ input }) => { + // TODO: delete from game where id = input.gameId + // cascade deletes gameSettings, profiles, profileMods + void input + return null as string | null + }), + + /** Set (or clear) the default game. Clears previous default first. */ + setDefault: publicProcedure + .input(z.object({ gameId: z.string().min(1).nullable() })) + .mutation(async ({ input }) => { + // TODO: transaction: + // 1. update game set isDefault = false where isDefault = true + // 2. if input.gameId != null: update game set isDefault = true where id = input.gameId + void input + }), + + /** Update lastAccessedAt to now */ + touch: publicProcedure + .input(z.object({ gameId: z.string().min(1) })) + .mutation(async ({ input }) => { + // TODO: update game set lastAccessedAt = new Date().toISOString() where id = input.gameId + void input + }), +}) diff --git a/electron/trpc/data/index.ts b/electron/trpc/data/index.ts new file mode 100644 index 0000000..07c780e --- /dev/null +++ b/electron/trpc/data/index.ts @@ -0,0 +1,12 @@ +import { t } from "../trpc" +import { dataGamesRouter } from "./games" +import { dataSettingsRouter } from "./settings" +import { dataProfilesRouter } from "./profiles" +import { dataModsRouter } from "./mods" + +export const dataRouter = t.router({ + games: dataGamesRouter, + settings: dataSettingsRouter, + profiles: dataProfilesRouter, + mods: dataModsRouter, +}) diff --git a/electron/trpc/data/mods.ts b/electron/trpc/data/mods.ts new file mode 100644 index 0000000..5d774a3 --- /dev/null +++ b/electron/trpc/data/mods.ts @@ -0,0 +1,171 @@ +import { z } from "zod" +import { t, publicProcedure } from "../trpc" + +type InstalledMod = { + modId: string + installedVersion: string + enabled: boolean + dependencyWarnings: string[] +} + +export const dataModsRouter = t.router({ + /** List all installed mods for a profile */ + listInstalled: publicProcedure + .input(z.object({ profileId: z.string().min(1) })) + .query(async ({ input }) => { + // TODO: select from profileMod where profileId = input.profileId + // map dependencyWarnings: JSON.parse(text) || [] + void input + return [] as InstalledMod[] + }), + + /** Check if a specific mod is installed */ + isInstalled: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + })) + .query(async ({ input }) => { + // TODO: select count(*) from profileMod where profileId and modId + void input + return false + }), + + /** Check if a mod is enabled */ + isEnabled: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + })) + .query(async ({ input }) => { + // TODO: select enabled from profileMod where profileId and modId + // return false if not found + void input + return false + }), + + /** Get the installed version of a mod */ + getInstalledVersion: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + })) + .query(async ({ input }) => { + // TODO: select installedVersion from profileMod where profileId and modId + // return undefined if not found + void input + return undefined as string | undefined + }), + + /** Get dependency warnings for a mod */ + getDependencyWarnings: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + })) + .query(async ({ input }) => { + // TODO: select dependencyWarnings from profileMod where profileId and modId + // JSON.parse or [] if null/not found + void input + return [] as string[] + }), + + /** Record a mod as installed (upsert) */ + install: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + version: z.string().min(1), + })) + .mutation(async ({ input }) => { + // TODO: insert into profileMod (profileId, modId, installedVersion, enabled=true) + // on conflict (profileId, modId) do update set installedVersion, enabled=true + void input + }), + + /** Remove a mod record from a profile */ + uninstall: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + })) + .mutation(async ({ input }) => { + // TODO: delete from profileMod where profileId and modId + void input + }), + + /** Remove all mod records from a profile. Returns count removed. */ + uninstallAll: publicProcedure + .input(z.object({ profileId: z.string().min(1) })) + .mutation(async ({ input }) => { + // TODO: delete from profileMod where profileId = input.profileId + // return rows affected + void input + return 0 + }), + + /** Enable a mod */ + enable: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + })) + .mutation(async ({ input }) => { + // TODO: update profileMod set enabled=true where profileId and modId + void input + }), + + /** Disable a mod */ + disable: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + })) + .mutation(async ({ input }) => { + // TODO: update profileMod set enabled=false where profileId and modId + void input + }), + + /** Toggle mod enabled state */ + toggle: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + })) + .mutation(async ({ input }) => { + // TODO: transaction: read current enabled, update to NOT enabled + void input + }), + + /** Set dependency warnings for a mod */ + setDependencyWarnings: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + warnings: z.array(z.string()), + })) + .mutation(async ({ input }) => { + // TODO: update profileMod set dependencyWarnings = JSON.stringify(input.warnings) + // where profileId and modId + void input + }), + + /** Clear dependency warnings for a mod */ + clearDependencyWarnings: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + })) + .mutation(async ({ input }) => { + // TODO: update profileMod set dependencyWarnings = null where profileId and modId + void input + }), + + /** Delete all mod state for a profile */ + deleteProfileState: publicProcedure + .input(z.object({ profileId: z.string().min(1) })) + .mutation(async ({ input }) => { + // TODO: delete from profileMod where profileId = input.profileId + void input + }), +}) diff --git a/electron/trpc/data/profiles.ts b/electron/trpc/data/profiles.ts new file mode 100644 index 0000000..bf29203 --- /dev/null +++ b/electron/trpc/data/profiles.ts @@ -0,0 +1,121 @@ +import { z } from "zod" +import { t, publicProcedure } from "../trpc" + +type Profile = { + id: string + name: string + createdAt: number +} + +export const dataProfilesRouter = t.router({ + /** List all profiles for a game */ + list: publicProcedure + .input(z.object({ gameId: z.string().min(1) })) + .query(async ({ input }) => { + // TODO: select from profile where gameId = input.gameId order by createdAt asc + // map createdAt ISO -> epoch ms + void input + return [] as Profile[] + }), + + /** Get the active profile for a game, or null */ + getActive: publicProcedure + .input(z.object({ gameId: z.string().min(1) })) + .query(async ({ input }) => { + // TODO: select from profile where gameId = input.gameId and isActive = true limit 1 + void input + return null as Profile | null + }), + + /** Ensure a default profile exists for a game. Returns the default profile id. */ + ensureDefault: publicProcedure + .input(z.object({ gameId: z.string().min(1) })) + .mutation(async ({ input }) => { + // TODO: transaction: + // 1. check if "{gameId}-default" exists + // 2. if not, insert (id="{gameId}-default", gameId, name="Default", isDefault=true) + // 3. if no active profile for game, set this as active + // 4. return the default profile id + void input + return "" as string + }), + + /** Create a new profile and set it as active */ + create: publicProcedure + .input(z.object({ + gameId: z.string().min(1), + name: z.string().min(1), + })) + .mutation(async ({ input }) => { + // TODO: transaction: + // 1. generate id: "{gameId}-{crypto.randomUUID()}" + // 2. deactivate current active profile (set isActive=false) + // 3. insert new profile (isActive=true, isDefault=false) + // 4. return created Profile + void input + return { id: "", name: input.name, createdAt: Date.now() } as Profile + }), + + /** Rename a profile */ + rename: publicProcedure + .input(z.object({ + gameId: z.string().min(1), + profileId: z.string().min(1), + newName: z.string().min(1), + })) + .mutation(async ({ input }) => { + // TODO: update profile set name = input.newName + // where id = input.profileId and gameId = input.gameId + void input + }), + + /** Remove a profile. Returns { deleted, reason? }. */ + remove: publicProcedure + .input(z.object({ + gameId: z.string().min(1), + profileId: z.string().min(1), + })) + .mutation(async ({ input }) => { + // TODO: + // 1. check if isDefault=true -> return {deleted:false, reason:"Cannot delete default profile"} + // 2. if isActive, pick adjacent or default as new active + // 3. delete profile row (cascade deletes profileMod rows) + // 4. return { deleted: true } + void input + return { deleted: false as boolean, reason: undefined as string | undefined } + }), + + /** Set a profile as active for its game */ + setActive: publicProcedure + .input(z.object({ + gameId: z.string().min(1), + profileId: z.string().min(1), + })) + .mutation(async ({ input }) => { + // TODO: transaction: + // 1. update profile set isActive=false where gameId and isActive=true + // 2. update profile set isActive=true where id = input.profileId + void input + }), + + /** Reset game to default profile only. Returns default profile id. */ + reset: publicProcedure + .input(z.object({ gameId: z.string().min(1) })) + .mutation(async ({ input }) => { + // TODO: transaction: + // 1. delete all non-default profiles (cascade cleans profileMod) + // 2. ensure default exists, set as active + // 3. return default profile id + void input + return "" as string + }), + + /** Remove all profiles for a game (used in unmanage flow) */ + removeAll: publicProcedure + .input(z.object({ gameId: z.string().min(1) })) + .mutation(async ({ input }) => { + // TODO: delete from profile where gameId = input.gameId + // FK cascade handles profileMod cleanup + void input + }), +}) diff --git a/electron/trpc/data/settings.ts b/electron/trpc/data/settings.ts new file mode 100644 index 0000000..b968a57 --- /dev/null +++ b/electron/trpc/data/settings.ts @@ -0,0 +1,172 @@ +import { z } from "zod" +import { t, publicProcedure } from "../trpc" + +// --------------------------------------------------------------------------- +// Zod schemas for partial updates +// --------------------------------------------------------------------------- + +const globalSettingsPartialSchema = z.object({ + dataFolder: z.string().optional(), + steamFolder: z.string().optional(), + modDownloadFolder: z.string().optional(), + cacheFolder: z.string().optional(), + speedLimitEnabled: z.boolean().optional(), + speedLimitBps: z.number().int().optional(), + speedUnit: z.enum(["Bps", "bps"]).optional(), + maxConcurrentDownloads: z.number().int().positive().optional(), + downloadCacheEnabled: z.boolean().optional(), + preferredThunderstoreCdn: z.string().optional(), + autoInstallMods: z.boolean().optional(), + enforceDependencyVersions: z.boolean().optional(), + cardDisplayType: z.enum(["collapsed", "expanded"]).optional(), + theme: z.enum(["dark", "light", "system"]).optional(), + language: z.string().optional(), + funkyMode: z.boolean().optional(), +}) + +const gameSettingsPartialSchema = z.object({ + gameInstallFolder: z.string().optional(), + modDownloadFolder: z.string().optional(), + cacheFolder: z.string().optional(), + modCacheFolder: z.string().optional(), + launchParameters: z.string().optional(), + onlineModListCacheDate: z.number().nullable().optional(), +}) + +// --------------------------------------------------------------------------- +// Full shape types (for return values) +// --------------------------------------------------------------------------- + +type GlobalSettings = { + dataFolder: string + steamFolder: string + modDownloadFolder: string + cacheFolder: string + speedLimitEnabled: boolean + speedLimitBps: number + speedUnit: "Bps" | "bps" + maxConcurrentDownloads: number + downloadCacheEnabled: boolean + preferredThunderstoreCdn: string + autoInstallMods: boolean + enforceDependencyVersions: boolean + cardDisplayType: "collapsed" | "expanded" + theme: "dark" | "light" | "system" + language: string + funkyMode: boolean +} + +type GameSettings = { + gameInstallFolder: string + modDownloadFolder: string + cacheFolder: string + modCacheFolder: string + launchParameters: string + onlineModListCacheDate: number | null +} + +type EffectiveGameSettings = GameSettings & { + modDownloadFolder: string + cacheFolder: string +} + +// --------------------------------------------------------------------------- +// Router +// --------------------------------------------------------------------------- + +const defaultGlobal: GlobalSettings = { + dataFolder: "", + steamFolder: "", + modDownloadFolder: "", + cacheFolder: "", + speedLimitEnabled: false, + speedLimitBps: 0, + speedUnit: "Bps", + maxConcurrentDownloads: 3, + downloadCacheEnabled: true, + preferredThunderstoreCdn: "main", + autoInstallMods: true, + enforceDependencyVersions: true, + cardDisplayType: "collapsed", + theme: "dark", + language: "en", + funkyMode: false, +} + +const defaultGameSettings: GameSettings = { + gameInstallFolder: "", + modDownloadFolder: "", + cacheFolder: "", + modCacheFolder: "", + launchParameters: "", + onlineModListCacheDate: null, +} + +export const dataSettingsRouter = t.router({ + /** Get global settings (singleton row, id=1) */ + getGlobal: publicProcedure.query(async () => { + // TODO: select from globalSettings where id = 1 + // if no row, insert default and return it + return defaultGlobal as GlobalSettings + }), + + /** Get raw per-game settings */ + getForGame: publicProcedure + .input(z.object({ gameId: z.string().min(1) })) + .query(async ({ input }) => { + // TODO: select from gameSettings where gameId = input.gameId + // if no row, return defaults + void input + return defaultGameSettings as GameSettings + }), + + /** Get effective game settings (per-game merged with global fallbacks) */ + getEffective: publicProcedure + .input(z.object({ gameId: z.string().min(1) })) + .query(async ({ input }) => { + // TODO: + // 1. fetch global settings (id=1) + // 2. fetch per-game settings (gameId) + // 3. merge: modDownloadFolder = perGame || global, cacheFolder = perGame || global + void input + return defaultGameSettings as EffectiveGameSettings + }), + + /** Partial update of global settings */ + updateGlobal: publicProcedure + .input(z.object({ updates: globalSettingsPartialSchema })) + .mutation(async ({ input }) => { + // TODO: update globalSettings set ...input.updates where id = 1 + void input + }), + + /** Partial update of per-game settings (upsert) */ + updateForGame: publicProcedure + .input(z.object({ + gameId: z.string().min(1), + updates: gameSettingsPartialSchema, + })) + .mutation(async ({ input }) => { + // TODO: upsert into gameSettings + // on conflict (gameId) do update set ...input.updates + void input + }), + + /** Reset per-game settings to defaults (keeps row, nulls overridable fields) */ + resetForGame: publicProcedure + .input(z.object({ gameId: z.string().min(1) })) + .mutation(async ({ input }) => { + // TODO: update gameSettings set modDownloadFolder=null, cacheFolder=null, + // gameInstallFolder='', modCacheFolder='', launchParameters='', + // onlineModListCacheDate=null where gameId = input.gameId + void input + }), + + /** Delete per-game settings row entirely */ + deleteForGame: publicProcedure + .input(z.object({ gameId: z.string().min(1) })) + .mutation(async ({ input }) => { + // TODO: delete from gameSettings where gameId = input.gameId + void input + }), +}) diff --git a/electron/trpc/router.ts b/electron/trpc/router.ts index f16cadc..795798e 100644 --- a/electron/trpc/router.ts +++ b/electron/trpc/router.ts @@ -1,10 +1,9 @@ -import { initTRPC } from "@trpc/server" import { app, dialog, shell } from "electron" import { promises as fs } from "fs" import { join } from "path" -import superjson from "superjson" import { z } from "zod" -import type { AppContext } from "./context" +import { t, publicProcedure } from "./trpc" +import { dataRouter } from "./data" import { searchPackages, getPackage } from "../thunderstore/search" import { resolveDependencies, resolveDependenciesRecursive } from "../thunderstore/dependencies" import { clearCatalog, getCategories, getCatalogStatus } from "../thunderstore/catalog" @@ -20,20 +19,6 @@ import { cleanupInjected } from "../launch/injection-tracker" import { checkBaseDependencies, installBaseDependencies } from "../launch/base-dependencies" import { getLogger } from "../file-logger" -/** - * Initialize tRPC with SuperJSON for rich data serialization - * (Date, Map, Set, BigInt, etc.) - */ -const t = initTRPC.context().create({ - isServer: true, - transformer: superjson, -}) - -/** - * Base procedure - all procedures inherit from this - */ -const publicProcedure = t.procedure - /** * Desktop/filesystem procedures * Migrated from existing ipcMain handlers @@ -1073,6 +1058,7 @@ export const appRouter = t.router({ launch: launchRouter, logs: logsRouter, config: configRouter, + data: dataRouter, }) /** diff --git a/electron/trpc/trpc.ts b/electron/trpc/trpc.ts new file mode 100644 index 0000000..d164c17 --- /dev/null +++ b/electron/trpc/trpc.ts @@ -0,0 +1,10 @@ +import { initTRPC } from "@trpc/server" +import superjson from "superjson" +import type { AppContext } from "./context" + +export const t = initTRPC.context().create({ + isServer: true, + transformer: superjson, +}) + +export const publicProcedure = t.procedure From 98edc7ae2b53ffcee360a5c76f19c88cec4422af Mon Sep 17 00:00:00 2001 From: akarachen Date: Sat, 7 Feb 2026 12:20:33 +0800 Subject: [PATCH 5/6] feat: migrate data handling to database for games, mods, profiles, and settings --- electron/trpc/data/games.ts | 68 +++++++--- electron/trpc/data/mods.ts | 142 ++++++++++++++------ electron/trpc/data/profiles.ts | 233 ++++++++++++++++++++++++++------- electron/trpc/data/settings.ts | 142 ++++++++++++++++---- 4 files changed, 456 insertions(+), 129 deletions(-) diff --git a/electron/trpc/data/games.ts b/electron/trpc/data/games.ts index f95057e..b35df51 100644 --- a/electron/trpc/data/games.ts +++ b/electron/trpc/data/games.ts @@ -1,5 +1,8 @@ import { z } from "zod" import { t, publicProcedure } from "../trpc" +import { getDb } from "../../db" +import { game } from "../../db/schema" +import { eq, desc, isNotNull } from "drizzle-orm" /** Convert ISO8601 string to epoch ms, or null */ export function isoToEpoch(iso: string | null | undefined): number | null { @@ -9,59 +12,84 @@ export function isoToEpoch(iso: string | null | undefined): number | null { export const dataGamesRouter = t.router({ /** Get all managed games */ list: publicProcedure.query(async () => { - // TODO: select all from game table, map lastAccessedAt via isoToEpoch - return [] as Array<{ id: string; isDefault: boolean; lastAccessedAt: number | null }> + const db = getDb() + const rows = await db.select().from(game) + return rows.map((r) => ({ + id: r.id, + isDefault: r.isDefault, + lastAccessedAt: isoToEpoch(r.lastAccessedAt), + })) }), /** Get the default game, or null */ getDefault: publicProcedure.query(async () => { - // TODO: select from game where isDefault = true limit 1 - return null as { id: string; isDefault: boolean; lastAccessedAt: number | null } | null + const db = getDb() + const rows = await db.select().from(game).where(eq(game.isDefault, true)).limit(1) + const r = rows[0] + if (!r) return null + return { + id: r.id, + isDefault: r.isDefault, + lastAccessedAt: isoToEpoch(r.lastAccessedAt), + } }), /** Get recently accessed games, ordered by lastAccessedAt desc */ getRecent: publicProcedure .input(z.object({ limit: z.number().int().positive().optional().default(10) })) .query(async ({ input }) => { - // TODO: select from game where lastAccessedAt IS NOT NULL - // order by lastAccessedAt desc, limit input.limit - void input - return [] as Array<{ id: string; isDefault: boolean; lastAccessedAt: number | null }> + const db = getDb() + const rows = await db + .select() + .from(game) + .where(isNotNull(game.lastAccessedAt)) + .orderBy(desc(game.lastAccessedAt)) + .limit(input.limit) + return rows.map((r) => ({ + id: r.id, + isDefault: r.isDefault, + lastAccessedAt: isoToEpoch(r.lastAccessedAt), + })) }), /** Add a game to the managed list */ add: publicProcedure .input(z.object({ gameId: z.string().min(1) })) .mutation(async ({ input }) => { - // TODO: insert into game (id) on conflict do nothing - void input + const db = getDb() + await db.insert(game).values({ id: input.gameId }).onConflictDoNothing() }), /** Remove a game. Returns the removed gameId or null if not found. */ remove: publicProcedure .input(z.object({ gameId: z.string().min(1) })) .mutation(async ({ input }) => { - // TODO: delete from game where id = input.gameId - // cascade deletes gameSettings, profiles, profileMods - void input - return null as string | null + const db = getDb() + const deleted = await db.delete(game).where(eq(game.id, input.gameId)).returning() + return deleted[0]?.id ?? null }), /** Set (or clear) the default game. Clears previous default first. */ setDefault: publicProcedure .input(z.object({ gameId: z.string().min(1).nullable() })) .mutation(async ({ input }) => { - // TODO: transaction: - // 1. update game set isDefault = false where isDefault = true - // 2. if input.gameId != null: update game set isDefault = true where id = input.gameId - void input + const db = getDb() + await db.transaction(async (tx) => { + await tx.update(game).set({ isDefault: false }).where(eq(game.isDefault, true)) + if (input.gameId != null) { + await tx.update(game).set({ isDefault: true }).where(eq(game.id, input.gameId)) + } + }) }), /** Update lastAccessedAt to now */ touch: publicProcedure .input(z.object({ gameId: z.string().min(1) })) .mutation(async ({ input }) => { - // TODO: update game set lastAccessedAt = new Date().toISOString() where id = input.gameId - void input + const db = getDb() + await db + .update(game) + .set({ lastAccessedAt: new Date().toISOString() }) + .where(eq(game.id, input.gameId)) }), }) diff --git a/electron/trpc/data/mods.ts b/electron/trpc/data/mods.ts index 5d774a3..6cf5db1 100644 --- a/electron/trpc/data/mods.ts +++ b/electron/trpc/data/mods.ts @@ -1,5 +1,8 @@ import { z } from "zod" import { t, publicProcedure } from "../trpc" +import { getDb } from "../../db" +import { profileMod } from "../../db/schema" +import { eq, and, sql } from "drizzle-orm" type InstalledMod = { modId: string @@ -8,15 +11,31 @@ type InstalledMod = { dependencyWarnings: string[] } +function parseWarnings(raw: string | null): string[] { + if (!raw) return [] + try { + return JSON.parse(raw) + } catch { + return [] + } +} + export const dataModsRouter = t.router({ /** List all installed mods for a profile */ listInstalled: publicProcedure .input(z.object({ profileId: z.string().min(1) })) .query(async ({ input }) => { - // TODO: select from profileMod where profileId = input.profileId - // map dependencyWarnings: JSON.parse(text) || [] - void input - return [] as InstalledMod[] + const db = getDb() + const rows = await db + .select() + .from(profileMod) + .where(eq(profileMod.profileId, input.profileId)) + return rows.map((r): InstalledMod => ({ + modId: r.modId, + installedVersion: r.installedVersion, + enabled: r.enabled, + dependencyWarnings: parseWarnings(r.dependencyWarnings), + })) }), /** Check if a specific mod is installed */ @@ -26,9 +45,13 @@ export const dataModsRouter = t.router({ modId: z.string().min(1), })) .query(async ({ input }) => { - // TODO: select count(*) from profileMod where profileId and modId - void input - return false + const db = getDb() + const rows = await db + .select({ id: profileMod.id }) + .from(profileMod) + .where(and(eq(profileMod.profileId, input.profileId), eq(profileMod.modId, input.modId))) + .limit(1) + return !!rows[0] }), /** Check if a mod is enabled */ @@ -38,10 +61,13 @@ export const dataModsRouter = t.router({ modId: z.string().min(1), })) .query(async ({ input }) => { - // TODO: select enabled from profileMod where profileId and modId - // return false if not found - void input - return false + const db = getDb() + const rows = await db + .select({ enabled: profileMod.enabled }) + .from(profileMod) + .where(and(eq(profileMod.profileId, input.profileId), eq(profileMod.modId, input.modId))) + .limit(1) + return rows[0]?.enabled ?? false }), /** Get the installed version of a mod */ @@ -51,10 +77,13 @@ export const dataModsRouter = t.router({ modId: z.string().min(1), })) .query(async ({ input }) => { - // TODO: select installedVersion from profileMod where profileId and modId - // return undefined if not found - void input - return undefined as string | undefined + const db = getDb() + const rows = await db + .select({ installedVersion: profileMod.installedVersion }) + .from(profileMod) + .where(and(eq(profileMod.profileId, input.profileId), eq(profileMod.modId, input.modId))) + .limit(1) + return rows[0]?.installedVersion as string | undefined }), /** Get dependency warnings for a mod */ @@ -64,10 +93,13 @@ export const dataModsRouter = t.router({ modId: z.string().min(1), })) .query(async ({ input }) => { - // TODO: select dependencyWarnings from profileMod where profileId and modId - // JSON.parse or [] if null/not found - void input - return [] as string[] + const db = getDb() + const rows = await db + .select({ dependencyWarnings: profileMod.dependencyWarnings }) + .from(profileMod) + .where(and(eq(profileMod.profileId, input.profileId), eq(profileMod.modId, input.modId))) + .limit(1) + return parseWarnings(rows[0]?.dependencyWarnings ?? null) }), /** Record a mod as installed (upsert) */ @@ -78,9 +110,19 @@ export const dataModsRouter = t.router({ version: z.string().min(1), })) .mutation(async ({ input }) => { - // TODO: insert into profileMod (profileId, modId, installedVersion, enabled=true) - // on conflict (profileId, modId) do update set installedVersion, enabled=true - void input + const db = getDb() + await db + .insert(profileMod) + .values({ + profileId: input.profileId, + modId: input.modId, + installedVersion: input.version, + enabled: true, + }) + .onConflictDoUpdate({ + target: [profileMod.profileId, profileMod.modId], + set: { installedVersion: input.version, enabled: true }, + }) }), /** Remove a mod record from a profile */ @@ -90,18 +132,22 @@ export const dataModsRouter = t.router({ modId: z.string().min(1), })) .mutation(async ({ input }) => { - // TODO: delete from profileMod where profileId and modId - void input + const db = getDb() + await db + .delete(profileMod) + .where(and(eq(profileMod.profileId, input.profileId), eq(profileMod.modId, input.modId))) }), /** Remove all mod records from a profile. Returns count removed. */ uninstallAll: publicProcedure .input(z.object({ profileId: z.string().min(1) })) .mutation(async ({ input }) => { - // TODO: delete from profileMod where profileId = input.profileId - // return rows affected - void input - return 0 + const db = getDb() + const deleted = await db + .delete(profileMod) + .where(eq(profileMod.profileId, input.profileId)) + .returning() + return deleted.length }), /** Enable a mod */ @@ -111,8 +157,11 @@ export const dataModsRouter = t.router({ modId: z.string().min(1), })) .mutation(async ({ input }) => { - // TODO: update profileMod set enabled=true where profileId and modId - void input + const db = getDb() + await db + .update(profileMod) + .set({ enabled: true }) + .where(and(eq(profileMod.profileId, input.profileId), eq(profileMod.modId, input.modId))) }), /** Disable a mod */ @@ -122,8 +171,11 @@ export const dataModsRouter = t.router({ modId: z.string().min(1), })) .mutation(async ({ input }) => { - // TODO: update profileMod set enabled=false where profileId and modId - void input + const db = getDb() + await db + .update(profileMod) + .set({ enabled: false }) + .where(and(eq(profileMod.profileId, input.profileId), eq(profileMod.modId, input.modId))) }), /** Toggle mod enabled state */ @@ -133,8 +185,11 @@ export const dataModsRouter = t.router({ modId: z.string().min(1), })) .mutation(async ({ input }) => { - // TODO: transaction: read current enabled, update to NOT enabled - void input + const db = getDb() + await db + .update(profileMod) + .set({ enabled: sql`NOT ${profileMod.enabled}` }) + .where(and(eq(profileMod.profileId, input.profileId), eq(profileMod.modId, input.modId))) }), /** Set dependency warnings for a mod */ @@ -145,9 +200,11 @@ export const dataModsRouter = t.router({ warnings: z.array(z.string()), })) .mutation(async ({ input }) => { - // TODO: update profileMod set dependencyWarnings = JSON.stringify(input.warnings) - // where profileId and modId - void input + const db = getDb() + await db + .update(profileMod) + .set({ dependencyWarnings: JSON.stringify(input.warnings) }) + .where(and(eq(profileMod.profileId, input.profileId), eq(profileMod.modId, input.modId))) }), /** Clear dependency warnings for a mod */ @@ -157,15 +214,18 @@ export const dataModsRouter = t.router({ modId: z.string().min(1), })) .mutation(async ({ input }) => { - // TODO: update profileMod set dependencyWarnings = null where profileId and modId - void input + const db = getDb() + await db + .update(profileMod) + .set({ dependencyWarnings: null }) + .where(and(eq(profileMod.profileId, input.profileId), eq(profileMod.modId, input.modId))) }), /** Delete all mod state for a profile */ deleteProfileState: publicProcedure .input(z.object({ profileId: z.string().min(1) })) .mutation(async ({ input }) => { - // TODO: delete from profileMod where profileId = input.profileId - void input + const db = getDb() + await db.delete(profileMod).where(eq(profileMod.profileId, input.profileId)) }), }) diff --git a/electron/trpc/data/profiles.ts b/electron/trpc/data/profiles.ts index bf29203..583e101 100644 --- a/electron/trpc/data/profiles.ts +++ b/electron/trpc/data/profiles.ts @@ -1,5 +1,10 @@ import { z } from "zod" import { t, publicProcedure } from "../trpc" +import { getDb } from "../../db" +import { profile } from "../../db/schema" +import { eq, and, asc } from "drizzle-orm" +import { randomUUID } from "crypto" +import { isoToEpoch } from "./games" type Profile = { id: string @@ -7,37 +12,80 @@ type Profile = { createdAt: number } +function rowToProfile(r: typeof profile.$inferSelect): Profile { + return { + id: r.id, + name: r.name, + createdAt: isoToEpoch(r.createdAt) ?? Date.now(), + } +} + export const dataProfilesRouter = t.router({ /** List all profiles for a game */ list: publicProcedure .input(z.object({ gameId: z.string().min(1) })) .query(async ({ input }) => { - // TODO: select from profile where gameId = input.gameId order by createdAt asc - // map createdAt ISO -> epoch ms - void input - return [] as Profile[] + const db = getDb() + const rows = await db + .select() + .from(profile) + .where(eq(profile.gameId, input.gameId)) + .orderBy(asc(profile.createdAt)) + return rows.map(rowToProfile) }), /** Get the active profile for a game, or null */ getActive: publicProcedure .input(z.object({ gameId: z.string().min(1) })) .query(async ({ input }) => { - // TODO: select from profile where gameId = input.gameId and isActive = true limit 1 - void input - return null as Profile | null + const db = getDb() + const rows = await db + .select() + .from(profile) + .where(and(eq(profile.gameId, input.gameId), eq(profile.isActive, true))) + .limit(1) + const r = rows[0] + return r ? rowToProfile(r) : null }), /** Ensure a default profile exists for a game. Returns the default profile id. */ ensureDefault: publicProcedure .input(z.object({ gameId: z.string().min(1) })) .mutation(async ({ input }) => { - // TODO: transaction: - // 1. check if "{gameId}-default" exists - // 2. if not, insert (id="{gameId}-default", gameId, name="Default", isDefault=true) - // 3. if no active profile for game, set this as active - // 4. return the default profile id - void input - return "" as string + const db = getDb() + const defaultId = `${input.gameId}-default` + + return await db.transaction(async (tx) => { + // Check if default profile exists + const existing = await tx + .select() + .from(profile) + .where(eq(profile.id, defaultId)) + .limit(1) + + if (!existing[0]) { + await tx.insert(profile).values({ + id: defaultId, + gameId: input.gameId, + name: "Default", + isDefault: true, + isActive: false, + }) + } + + // If no active profile for this game, set default as active + const active = await tx + .select() + .from(profile) + .where(and(eq(profile.gameId, input.gameId), eq(profile.isActive, true))) + .limit(1) + + if (!active[0]) { + await tx.update(profile).set({ isActive: true }).where(eq(profile.id, defaultId)) + } + + return defaultId + }) }), /** Create a new profile and set it as active */ @@ -47,13 +95,33 @@ export const dataProfilesRouter = t.router({ name: z.string().min(1), })) .mutation(async ({ input }) => { - // TODO: transaction: - // 1. generate id: "{gameId}-{crypto.randomUUID()}" - // 2. deactivate current active profile (set isActive=false) - // 3. insert new profile (isActive=true, isDefault=false) - // 4. return created Profile - void input - return { id: "", name: input.name, createdAt: Date.now() } as Profile + const db = getDb() + const newId = `${input.gameId}-${randomUUID()}` + + return await db.transaction(async (tx) => { + // Deactivate current active profile + await tx + .update(profile) + .set({ isActive: false }) + .where(and(eq(profile.gameId, input.gameId), eq(profile.isActive, true))) + + // Insert new profile as active + const now = new Date().toISOString() + await tx.insert(profile).values({ + id: newId, + gameId: input.gameId, + name: input.name, + isDefault: false, + isActive: true, + createdAt: now, + }) + + return { + id: newId, + name: input.name, + createdAt: new Date(now).getTime(), + } as Profile + }) }), /** Rename a profile */ @@ -64,9 +132,11 @@ export const dataProfilesRouter = t.router({ newName: z.string().min(1), })) .mutation(async ({ input }) => { - // TODO: update profile set name = input.newName - // where id = input.profileId and gameId = input.gameId - void input + const db = getDb() + await db + .update(profile) + .set({ name: input.newName }) + .where(and(eq(profile.id, input.profileId), eq(profile.gameId, input.gameId))) }), /** Remove a profile. Returns { deleted, reason? }. */ @@ -76,13 +146,56 @@ export const dataProfilesRouter = t.router({ profileId: z.string().min(1), })) .mutation(async ({ input }) => { - // TODO: - // 1. check if isDefault=true -> return {deleted:false, reason:"Cannot delete default profile"} - // 2. if isActive, pick adjacent or default as new active - // 3. delete profile row (cascade deletes profileMod rows) - // 4. return { deleted: true } - void input - return { deleted: false as boolean, reason: undefined as string | undefined } + const db = getDb() + + return await db.transaction(async (tx) => { + // Check if it's the default profile + const target = await tx + .select() + .from(profile) + .where(and(eq(profile.id, input.profileId), eq(profile.gameId, input.gameId))) + .limit(1) + + if (!target[0]) { + return { deleted: false as boolean, reason: "Profile not found" as string | undefined } + } + + if (target[0].isDefault) { + return { deleted: false as boolean, reason: "Cannot delete default profile" as string | undefined } + } + + // If this profile is active, reassign active to default or another profile + if (target[0].isActive) { + const defaultId = `${input.gameId}-default` + // Try default profile first + const defaultProfile = await tx + .select() + .from(profile) + .where(eq(profile.id, defaultId)) + .limit(1) + + if (defaultProfile[0]) { + await tx.update(profile).set({ isActive: true }).where(eq(profile.id, defaultId)) + } else { + // Fall back to any other profile + const other = await tx + .select() + .from(profile) + .where(and( + eq(profile.gameId, input.gameId), + eq(profile.isActive, false), + )) + .limit(1) + if (other[0]) { + await tx.update(profile).set({ isActive: true }).where(eq(profile.id, other[0].id)) + } + } + } + + // Delete the profile (cascade deletes profileMod rows) + await tx.delete(profile).where(eq(profile.id, input.profileId)) + return { deleted: true as boolean, reason: undefined as string | undefined } + }) }), /** Set a profile as active for its game */ @@ -92,30 +205,62 @@ export const dataProfilesRouter = t.router({ profileId: z.string().min(1), })) .mutation(async ({ input }) => { - // TODO: transaction: - // 1. update profile set isActive=false where gameId and isActive=true - // 2. update profile set isActive=true where id = input.profileId - void input + const db = getDb() + await db.transaction(async (tx) => { + // Deactivate all profiles for this game + await tx + .update(profile) + .set({ isActive: false }) + .where(and(eq(profile.gameId, input.gameId), eq(profile.isActive, true))) + // Activate target + await tx + .update(profile) + .set({ isActive: true }) + .where(eq(profile.id, input.profileId)) + }) }), /** Reset game to default profile only. Returns default profile id. */ reset: publicProcedure .input(z.object({ gameId: z.string().min(1) })) .mutation(async ({ input }) => { - // TODO: transaction: - // 1. delete all non-default profiles (cascade cleans profileMod) - // 2. ensure default exists, set as active - // 3. return default profile id - void input - return "" as string + const db = getDb() + const defaultId = `${input.gameId}-default` + + return await db.transaction(async (tx) => { + // Delete all non-default profiles (cascade cleans profileMod) + await tx + .delete(profile) + .where(and(eq(profile.gameId, input.gameId), eq(profile.isDefault, false))) + + // Ensure default profile exists + const existing = await tx + .select() + .from(profile) + .where(eq(profile.id, defaultId)) + .limit(1) + + if (!existing[0]) { + await tx.insert(profile).values({ + id: defaultId, + gameId: input.gameId, + name: "Default", + isDefault: true, + isActive: true, + }) + } else { + await tx.update(profile).set({ isActive: true }).where(eq(profile.id, defaultId)) + } + + return defaultId + }) }), /** Remove all profiles for a game (used in unmanage flow) */ removeAll: publicProcedure .input(z.object({ gameId: z.string().min(1) })) .mutation(async ({ input }) => { - // TODO: delete from profile where gameId = input.gameId - // FK cascade handles profileMod cleanup - void input + const db = getDb() + await db.delete(profile).where(eq(profile.gameId, input.gameId)) }), }) diff --git a/electron/trpc/data/settings.ts b/electron/trpc/data/settings.ts index b968a57..309684c 100644 --- a/electron/trpc/data/settings.ts +++ b/electron/trpc/data/settings.ts @@ -1,5 +1,9 @@ import { z } from "zod" import { t, publicProcedure } from "../trpc" +import { getDb } from "../../db" +import { globalSettings, gameSettings } from "../../db/schema" +import { eq } from "drizzle-orm" +import { isoToEpoch } from "./games" // --------------------------------------------------------------------------- // Zod schemas for partial updates @@ -102,42 +106,112 @@ const defaultGameSettings: GameSettings = { onlineModListCacheDate: null, } +/** Ensure global settings row exists and return it */ +async function ensureGlobalRow(): Promise { + const db = getDb() + const rows = await db.select().from(globalSettings).where(eq(globalSettings.id, 1)).limit(1) + if (rows[0]) { + return rowToGlobalSettings(rows[0]) + } + await db.insert(globalSettings).values({ id: 1 }).onConflictDoNothing() + const inserted = await db.select().from(globalSettings).where(eq(globalSettings.id, 1)).limit(1) + return inserted[0] ? rowToGlobalSettings(inserted[0]) : defaultGlobal +} + +function rowToGlobalSettings(r: typeof globalSettings.$inferSelect): GlobalSettings { + return { + dataFolder: r.dataFolder, + steamFolder: r.steamFolder, + modDownloadFolder: r.modDownloadFolder, + cacheFolder: r.cacheFolder, + speedLimitEnabled: r.speedLimitEnabled, + speedLimitBps: r.speedLimitBps, + speedUnit: r.speedUnit as GlobalSettings["speedUnit"], + maxConcurrentDownloads: r.maxConcurrentDownloads, + downloadCacheEnabled: r.downloadCacheEnabled, + preferredThunderstoreCdn: r.preferredThunderstoreCdn, + autoInstallMods: r.autoInstallMods, + enforceDependencyVersions: r.enforceDependencyVersions, + cardDisplayType: r.cardDisplayType as GlobalSettings["cardDisplayType"], + theme: r.theme as GlobalSettings["theme"], + language: r.language, + funkyMode: r.funkyMode, + } +} + +function rowToGameSettings(r: typeof gameSettings.$inferSelect): GameSettings { + return { + gameInstallFolder: r.gameInstallFolder, + modDownloadFolder: r.modDownloadFolder ?? "", + cacheFolder: r.cacheFolder ?? "", + modCacheFolder: r.modCacheFolder, + launchParameters: r.launchParameters, + onlineModListCacheDate: isoToEpoch(r.onlineModListCacheDate), + } +} + export const dataSettingsRouter = t.router({ /** Get global settings (singleton row, id=1) */ getGlobal: publicProcedure.query(async () => { - // TODO: select from globalSettings where id = 1 - // if no row, insert default and return it - return defaultGlobal as GlobalSettings + return ensureGlobalRow() }), /** Get raw per-game settings */ getForGame: publicProcedure .input(z.object({ gameId: z.string().min(1) })) .query(async ({ input }) => { - // TODO: select from gameSettings where gameId = input.gameId - // if no row, return defaults - void input - return defaultGameSettings as GameSettings + const db = getDb() + const rows = await db + .select() + .from(gameSettings) + .where(eq(gameSettings.gameId, input.gameId)) + .limit(1) + if (!rows[0]) return defaultGameSettings + return rowToGameSettings(rows[0]) }), /** Get effective game settings (per-game merged with global fallbacks) */ getEffective: publicProcedure .input(z.object({ gameId: z.string().min(1) })) .query(async ({ input }) => { - // TODO: - // 1. fetch global settings (id=1) - // 2. fetch per-game settings (gameId) - // 3. merge: modDownloadFolder = perGame || global, cacheFolder = perGame || global - void input - return defaultGameSettings as EffectiveGameSettings + const db = getDb() + const global = await ensureGlobalRow() + const rows = await db + .select() + .from(gameSettings) + .where(eq(gameSettings.gameId, input.gameId)) + .limit(1) + const perGame = rows[0] + + if (!perGame) { + return { + ...defaultGameSettings, + modDownloadFolder: global.modDownloadFolder, + cacheFolder: global.cacheFolder, + } as EffectiveGameSettings + } + + return { + gameInstallFolder: perGame.gameInstallFolder, + modDownloadFolder: perGame.modDownloadFolder ?? global.modDownloadFolder, + cacheFolder: perGame.cacheFolder ?? global.cacheFolder, + modCacheFolder: perGame.modCacheFolder, + launchParameters: perGame.launchParameters, + onlineModListCacheDate: isoToEpoch(perGame.onlineModListCacheDate), + } as EffectiveGameSettings }), /** Partial update of global settings */ updateGlobal: publicProcedure .input(z.object({ updates: globalSettingsPartialSchema })) .mutation(async ({ input }) => { - // TODO: update globalSettings set ...input.updates where id = 1 - void input + const db = getDb() + // Ensure row exists first + await db.insert(globalSettings).values({ id: 1 }).onConflictDoNothing() + await db + .update(globalSettings) + .set(input.updates) + .where(eq(globalSettings.id, 1)) }), /** Partial update of per-game settings (upsert) */ @@ -147,26 +221,46 @@ export const dataSettingsRouter = t.router({ updates: gameSettingsPartialSchema, })) .mutation(async ({ input }) => { - // TODO: upsert into gameSettings - // on conflict (gameId) do update set ...input.updates - void input + const db = getDb() + // Convert epoch ms back to ISO for storage + const updates: Record = { ...input.updates } + if (input.updates.onlineModListCacheDate !== undefined) { + updates.onlineModListCacheDate = input.updates.onlineModListCacheDate != null + ? new Date(input.updates.onlineModListCacheDate).toISOString() + : null + } + await db + .insert(gameSettings) + .values({ gameId: input.gameId, ...updates }) + .onConflictDoUpdate({ + target: gameSettings.gameId, + set: updates, + }) }), /** Reset per-game settings to defaults (keeps row, nulls overridable fields) */ resetForGame: publicProcedure .input(z.object({ gameId: z.string().min(1) })) .mutation(async ({ input }) => { - // TODO: update gameSettings set modDownloadFolder=null, cacheFolder=null, - // gameInstallFolder='', modCacheFolder='', launchParameters='', - // onlineModListCacheDate=null where gameId = input.gameId - void input + const db = getDb() + await db + .update(gameSettings) + .set({ + modDownloadFolder: null, + cacheFolder: null, + gameInstallFolder: "", + modCacheFolder: "", + launchParameters: "", + onlineModListCacheDate: null, + }) + .where(eq(gameSettings.gameId, input.gameId)) }), /** Delete per-game settings row entirely */ deleteForGame: publicProcedure .input(z.object({ gameId: z.string().min(1) })) .mutation(async ({ input }) => { - // TODO: delete from gameSettings where gameId = input.gameId - void input + const db = getDb() + await db.delete(gameSettings).where(eq(gameSettings.gameId, input.gameId)) }), }) From 416ae993c21b14f3dabc5dffad72ddf5ada54bda Mon Sep 17 00:00:00 2001 From: akarachen Date: Sat, 7 Feb 2026 12:58:33 +0800 Subject: [PATCH 6/6] feat: implement tRPC services and data handling for database mode --- src/data/datasource.ts | 10 + src/data/hooks.ts | 459 ++++++++++++++++++++++++++++++-------- src/data/services.ts | 35 ++- src/data/trpc-services.ts | 239 ++++++++++++++++++++ src/lib/trpc.tsx | 19 ++ src/vite-env.d.ts | 1 + 6 files changed, 662 insertions(+), 101 deletions(-) create mode 100644 src/data/datasource.ts create mode 100644 src/data/trpc-services.ts diff --git a/src/data/datasource.ts b/src/data/datasource.ts new file mode 100644 index 0000000..c7a88f1 --- /dev/null +++ b/src/data/datasource.ts @@ -0,0 +1,10 @@ +/** + * Resolved datasource mode. Read once at module load time — Vite statically + * replaces `import.meta.env.VITE_DATASOURCE` at build time so this is + * effectively a compile-time constant and allows tree-shaking of unused paths. + */ +export const DATASOURCE: "db" | "zustand" = + (import.meta.env.VITE_DATASOURCE as "db" | "zustand" | undefined) ?? "db" + +export const isDbMode = DATASOURCE === "db" +export const isZustandMode = DATASOURCE === "zustand" diff --git a/src/data/hooks.ts b/src/data/hooks.ts index dc5c738..0721d71 100644 --- a/src/data/hooks.ts +++ b/src/data/hooks.ts @@ -2,10 +2,10 @@ * React hooks – the stable API that components import. * * Data hooks: useSuspenseQuery → return type is always T (never undefined). - * This matches the Zustand selector return shapes exactly, - * so components only need to swap the hook call. + * Hook return shapes are identical regardless of VITE_DATASOURCE. * Action hooks: call async service functions directly. - * DataBridge handles cache invalidation when Zustand changes. + * In Zustand mode, DataBridge handles cache invalidation. + * In DB mode, action hooks explicitly invalidate after mutations. * * Pattern for component migration: * Before: const x = useProfileStore((s) => s.profilesByGame) @@ -26,6 +26,7 @@ import { modService, } from "./services" import type { GlobalSettings, GameSettings } from "./interfaces" +import { isDbMode, isZustandMode } from "./datasource" // --------------------------------------------------------------------------- // Query keys @@ -46,6 +47,10 @@ export function DataBridge() { const queryClient = useQueryClient() useEffect(() => { + // In DB mode, data store subscriptions are not needed — mutations + // explicitly invalidate queries. No-op. + if (isDbMode) return + const unsubs = [ useGameManagementStore.subscribe(() => { queryClient.invalidateQueries({ queryKey: dataKeys.gameManagement }) @@ -80,14 +85,18 @@ export function useGameManagementData(): GameManagementData { const { data } = useSuspenseQuery({ queryKey: dataKeys.gameManagement, queryFn: async (): Promise => { - const s = useGameManagementStore.getState() - return { - managedGameIds: s.managedGameIds, - recentManagedGameIds: s.recentManagedGameIds, - defaultGameId: s.defaultGameId, + if (isDbMode) { + const [games, recentGames, defaultGame] = await Promise.all([ + gameService.list(), + gameService.getRecent(), + gameService.getDefault(), + ]) + return { + managedGameIds: games.map((g) => g.id), + recentManagedGameIds: recentGames.map((g) => g.id), + defaultGameId: defaultGame?.id ?? null, + } } - }, - initialData: (): GameManagementData => { const s = useGameManagementStore.getState() return { managedGameIds: s.managedGameIds, @@ -95,21 +104,57 @@ export function useGameManagementData(): GameManagementData { defaultGameId: s.defaultGameId, } }, + ...(isZustandMode && { + initialData: (): GameManagementData => { + const s = useGameManagementStore.getState() + return { + managedGameIds: s.managedGameIds, + recentManagedGameIds: s.recentManagedGameIds, + defaultGameId: s.defaultGameId, + } + }, + }), staleTime: Infinity, }) return data } export function useGameManagementActions() { + const queryClient = useQueryClient() + return useMemo( () => ({ - addManagedGame: (gameId: string) => gameService.add(gameId), - removeManagedGame: (gameId: string) => gameService.remove(gameId), - setDefaultGameId: (gameId: string | null) => - gameService.setDefault(gameId), - appendRecentManagedGame: (gameId: string) => gameService.touch(gameId), + addManagedGame: async (gameId: string) => { + await gameService.add(gameId) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.gameManagement, + }) + }, + removeManagedGame: async (gameId: string) => { + const result = await gameService.remove(gameId) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.gameManagement, + }) + return result + }, + setDefaultGameId: async (gameId: string | null) => { + await gameService.setDefault(gameId) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.gameManagement, + }) + }, + appendRecentManagedGame: async (gameId: string) => { + await gameService.touch(gameId) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.gameManagement, + }) + }, }), - [] + [queryClient], ) } @@ -137,19 +182,40 @@ export function useSettingsData(): SettingsData { const { data } = useSuspenseQuery({ queryKey: dataKeys.settings, queryFn: async () => { - const s = useSettingsStore.getState() - return { - global: { ...s.global }, - perGame: { ...s.perGame } as Record, + if (isDbMode) { + const [global, games] = await Promise.all([ + settingsService.getGlobal(), + gameService.list(), + ]) + const perGameEntries = await Promise.all( + games.map( + async (g) => + [g.id, await settingsService.getForGame(g.id)] as const, + ), + ) + return { + global, + perGame: Object.fromEntries(perGameEntries) as Record< + string, + GameSettings + >, + } } - }, - initialData: () => { const s = useSettingsStore.getState() return { global: { ...s.global }, perGame: { ...s.perGame } as Record, } }, + ...(isZustandMode && { + initialData: () => { + const s = useSettingsStore.getState() + return { + global: { ...s.global }, + perGame: { ...s.perGame } as Record, + } + }, + }), staleTime: Infinity, }) @@ -158,24 +224,50 @@ export function useSettingsData(): SettingsData { ...defaultGameSettings, ...data.perGame[gameId], }), - [data.perGame] + [data.perGame], ) return { global: data.global, perGame: data.perGame, getPerGame } } export function useSettingsActions() { + const queryClient = useQueryClient() + return useMemo( () => ({ - updateGlobal: (updates: Partial) => - settingsService.updateGlobal(updates), - updatePerGame: (gameId: string, updates: Partial) => - settingsService.updateForGame(gameId, updates), - resetPerGame: (gameId: string) => settingsService.resetForGame(gameId), - deletePerGame: (gameId: string) => - settingsService.deleteForGame(gameId), + updateGlobal: async (updates: Partial) => { + await settingsService.updateGlobal(updates) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.settings, + }) + }, + updatePerGame: async ( + gameId: string, + updates: Partial, + ) => { + await settingsService.updateForGame(gameId, updates) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.settings, + }) + }, + resetPerGame: async (gameId: string) => { + await settingsService.resetForGame(gameId) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.settings, + }) + }, + deletePerGame: async (gameId: string) => { + await settingsService.deleteForGame(gameId) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.settings, + }) + }, }), - [] + [queryClient], ) } @@ -192,46 +284,112 @@ export function useProfileData(): ProfileData { const { data } = useSuspenseQuery({ queryKey: dataKeys.profiles, queryFn: async (): Promise => { - const s = useProfileStore.getState() - return { - profilesByGame: s.profilesByGame, - activeProfileIdByGame: s.activeProfileIdByGame, + if (isDbMode) { + const games = await gameService.list() + const entries = await Promise.all( + games.map(async (g) => { + const [profiles, active] = await Promise.all([ + profileService.list(g.id), + profileService.getActive(g.id), + ]) + return { gameId: g.id, profiles, activeId: active?.id ?? "" } + }), + ) + return { + profilesByGame: Object.fromEntries( + entries.map((e) => [e.gameId, e.profiles]), + ), + activeProfileIdByGame: Object.fromEntries( + entries + .filter((e) => e.activeId) + .map((e) => [e.gameId, e.activeId]), + ), + } } - }, - initialData: (): ProfileData => { const s = useProfileStore.getState() return { profilesByGame: s.profilesByGame, activeProfileIdByGame: s.activeProfileIdByGame, } }, + ...(isZustandMode && { + initialData: (): ProfileData => { + const s = useProfileStore.getState() + return { + profilesByGame: s.profilesByGame, + activeProfileIdByGame: s.activeProfileIdByGame, + } + }, + }), staleTime: Infinity, }) return data } export function useProfileActions() { + const queryClient = useQueryClient() + return useMemo( () => ({ - ensureDefaultProfile: (gameId: string) => - profileService.ensureDefault(gameId), - setActiveProfile: (gameId: string, profileId: string) => - profileService.setActive(gameId, profileId), - createProfile: (gameId: string, name: string) => - profileService.create(gameId, name), - renameProfile: ( + ensureDefaultProfile: async (gameId: string) => { + const result = await profileService.ensureDefault(gameId) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.profiles, + }) + return result + }, + setActiveProfile: async (gameId: string, profileId: string) => { + await profileService.setActive(gameId, profileId) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.profiles, + }) + }, + createProfile: async (gameId: string, name: string) => { + const result = await profileService.create(gameId, name) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.profiles, + }) + return result + }, + renameProfile: async ( gameId: string, profileId: string, - newName: string - ) => profileService.rename(gameId, profileId, newName), - deleteProfile: (gameId: string, profileId: string) => - profileService.remove(gameId, profileId), - resetGameProfilesToDefault: (gameId: string) => - profileService.reset(gameId), - removeGameProfiles: (gameId: string) => - profileService.removeAll(gameId), + newName: string, + ) => { + await profileService.rename(gameId, profileId, newName) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.profiles, + }) + }, + deleteProfile: async (gameId: string, profileId: string) => { + const result = await profileService.remove(gameId, profileId) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.profiles, + }) + return result + }, + resetGameProfilesToDefault: async (gameId: string) => { + const result = await profileService.reset(gameId) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.profiles, + }) + return result + }, + removeGameProfiles: async (gameId: string) => { + await profileService.removeAll(gameId) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.profiles, + }) + }, }), - [] + [queryClient], ) } @@ -239,11 +397,14 @@ export function useProfileActions() { // Mod Management // =========================================================================== -type ModManagementData = { +type ModManagementQueryData = { installedModsByProfile: Record> enabledModsByProfile: Record> installedModVersionsByProfile: Record> dependencyWarningsByProfile: Record> +} + +type ModManagementData = ModManagementQueryData & { uninstallingMods: Set // Derived helpers (matching store method signatures) isModInstalled: (profileId: string, modId: string) => boolean @@ -251,34 +412,90 @@ type ModManagementData = { getInstalledModIds: (profileId: string) => string[] getInstalledVersion: ( profileId: string, - modId: string + modId: string, ) => string | undefined getDependencyWarnings: (profileId: string, modId: string) => string[] } export function useModManagementData(): ModManagementData { + // uninstallingMods is UI state — always from Zustand regardless of VITE_DATASOURCE. + // Subscribe directly so it doesn't trigger a full DB re-fetch. + const uninstallingMods = useModManagementStore((s) => s.uninstallingMods) + const { data } = useSuspenseQuery({ queryKey: dataKeys.modManagement, - queryFn: async () => { - const s = useModManagementStore.getState() - return { - installedModsByProfile: s.installedModsByProfile, - enabledModsByProfile: s.enabledModsByProfile, - installedModVersionsByProfile: s.installedModVersionsByProfile, - dependencyWarningsByProfile: s.dependencyWarningsByProfile, - uninstallingMods: s.uninstallingMods, + queryFn: async (): Promise => { + if (isDbMode) { + const games = await gameService.list() + const allProfiles = ( + await Promise.all(games.map((g) => profileService.list(g.id))) + ).flat() + + const profileMods = await Promise.all( + allProfiles.map(async (p) => ({ + profileId: p.id, + mods: await modService.listInstalled(p.id), + })), + ) + + const installedModsByProfile: Record> = {} + const enabledModsByProfile: Record> = {} + const installedModVersionsByProfile: Record< + string, + Record + > = {} + const dependencyWarningsByProfile: Record< + string, + Record + > = {} + + for (const { profileId, mods } of profileMods) { + const installed = new Set() + const enabled = new Set() + const versions: Record = {} + const warnings: Record = {} + + for (const mod of mods) { + installed.add(mod.modId) + if (mod.enabled) enabled.add(mod.modId) + versions[mod.modId] = mod.installedVersion + if (mod.dependencyWarnings.length > 0) { + warnings[mod.modId] = mod.dependencyWarnings + } + } + + installedModsByProfile[profileId] = installed + enabledModsByProfile[profileId] = enabled + installedModVersionsByProfile[profileId] = versions + dependencyWarningsByProfile[profileId] = warnings + } + + return { + installedModsByProfile, + enabledModsByProfile, + installedModVersionsByProfile, + dependencyWarningsByProfile, + } } - }, - initialData: () => { const s = useModManagementStore.getState() return { installedModsByProfile: s.installedModsByProfile, enabledModsByProfile: s.enabledModsByProfile, installedModVersionsByProfile: s.installedModVersionsByProfile, dependencyWarningsByProfile: s.dependencyWarningsByProfile, - uninstallingMods: s.uninstallingMods, } }, + ...(isZustandMode && { + initialData: (): ModManagementQueryData => { + const s = useModManagementStore.getState() + return { + installedModsByProfile: s.installedModsByProfile, + enabledModsByProfile: s.enabledModsByProfile, + installedModVersionsByProfile: s.installedModVersionsByProfile, + dependencyWarningsByProfile: s.dependencyWarningsByProfile, + } + }, + }), staleTime: Infinity, structuralSharing: false, // Sets don't survive structural sharing }) @@ -289,7 +506,7 @@ export function useModManagementData(): ModManagementData { const set = data.installedModsByProfile[profileId] return set ? set.has(modId) : false }, - [data.installedModsByProfile] + [data.installedModsByProfile], ) const isModEnabled = useCallback( @@ -297,7 +514,7 @@ export function useModManagementData(): ModManagementData { const set = data.enabledModsByProfile[profileId] return set ? set.has(modId) : false }, - [data.enabledModsByProfile] + [data.enabledModsByProfile], ) const getInstalledModIds = useCallback( @@ -305,7 +522,7 @@ export function useModManagementData(): ModManagementData { const set = data.installedModsByProfile[profileId] return set ? Array.from(set) : [] }, - [data.installedModsByProfile] + [data.installedModsByProfile], ) const getInstalledVersion = useCallback( @@ -313,7 +530,7 @@ export function useModManagementData(): ModManagementData { const map = data.installedModVersionsByProfile[profileId] return map ? map[modId] : undefined }, - [data.installedModVersionsByProfile] + [data.installedModVersionsByProfile], ) const getDependencyWarnings = useCallback( @@ -321,11 +538,12 @@ export function useModManagementData(): ModManagementData { const map = data.dependencyWarningsByProfile[profileId] return map ? map[modId] || [] : [] }, - [data.dependencyWarningsByProfile] + [data.dependencyWarningsByProfile], ) return { ...data, + uninstallingMods, isModInstalled, isModEnabled, getInstalledModIds, @@ -335,31 +553,84 @@ export function useModManagementData(): ModManagementData { } export function useModManagementActions() { + const queryClient = useQueryClient() + return useMemo( () => ({ - installMod: (profileId: string, modId: string, version: string) => - modService.install(profileId, modId, version), - uninstallMod: (profileId: string, modId: string) => - modService.uninstall(profileId, modId), - uninstallAllMods: (profileId: string) => - modService.uninstallAll(profileId), - enableMod: (profileId: string, modId: string) => - modService.enable(profileId, modId), - disableMod: (profileId: string, modId: string) => - modService.disable(profileId, modId), - toggleMod: (profileId: string, modId: string) => - modService.toggle(profileId, modId), - setDependencyWarnings: ( + installMod: async ( + profileId: string, + modId: string, + version: string, + ) => { + await modService.install(profileId, modId, version) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.modManagement, + }) + }, + uninstallMod: async (profileId: string, modId: string) => { + await modService.uninstall(profileId, modId) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.modManagement, + }) + }, + uninstallAllMods: async (profileId: string) => { + const result = await modService.uninstallAll(profileId) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.modManagement, + }) + return result + }, + enableMod: async (profileId: string, modId: string) => { + await modService.enable(profileId, modId) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.modManagement, + }) + }, + disableMod: async (profileId: string, modId: string) => { + await modService.disable(profileId, modId) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.modManagement, + }) + }, + toggleMod: async (profileId: string, modId: string) => { + await modService.toggle(profileId, modId) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.modManagement, + }) + }, + setDependencyWarnings: async ( profileId: string, modId: string, - warnings: string[] - ) => modService.setDependencyWarnings(profileId, modId, warnings), - clearDependencyWarnings: (profileId: string, modId: string) => - modService.clearDependencyWarnings(profileId, modId), - deleteProfileState: (profileId: string) => - modService.deleteProfileState(profileId), + warnings: string[], + ) => { + await modService.setDependencyWarnings(profileId, modId, warnings) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.modManagement, + }) + }, + clearDependencyWarnings: async (profileId: string, modId: string) => { + await modService.clearDependencyWarnings(profileId, modId) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.modManagement, + }) + }, + deleteProfileState: async (profileId: string) => { + await modService.deleteProfileState(profileId) + if (isDbMode) + await queryClient.invalidateQueries({ + queryKey: dataKeys.modManagement, + }) + }, }), - [] + [queryClient], ) } @@ -375,13 +646,13 @@ export function useUnmanageGame() { return useCallback( async (gameId: string) => { - const profiles = - useProfileStore.getState().profilesByGame[gameId] ?? [] + // Use service to get profiles (works in both Zustand and DB mode) + const profiles = await profileService.list(gameId) await Promise.all(profiles.map((p) => modMut.deleteProfileState(p.id))) await profileMut.removeGameProfiles(gameId) await settingsMut.deletePerGame(gameId) return gameMut.removeManagedGame(gameId) }, - [gameMut, settingsMut, profileMut, modMut] + [gameMut, settingsMut, profileMut, modMut], ) } diff --git a/src/data/services.ts b/src/data/services.ts index a717790..433b298 100644 --- a/src/data/services.ts +++ b/src/data/services.ts @@ -1,10 +1,13 @@ /** - * Service singletons – separated from index.ts to break circular dependency - * with hooks.ts. + * Service singletons – conditionally backed by Zustand or tRPC/DB based on + * the VITE_DATASOURCE build flag. * - * Swap the implementation here when migrating from Zustand to DB/tRPC. + * Vite replaces `import.meta.env.VITE_DATASOURCE` at compile time, so the + * unused branch is tree-shaken in production builds. */ +import { isDbMode } from "./datasource" + import { createZustandGameService, createZustandSettingsService, @@ -12,7 +15,25 @@ import { createZustandModService, } from "./zustand" -export const gameService = createZustandGameService() -export const settingsService = createZustandSettingsService() -export const profileService = createZustandProfileService() -export const modService = createZustandModService() +import { + createTRPCGameService, + createTRPCSettingsService, + createTRPCProfileService, + createTRPCModService, +} from "./trpc-services" + +export const gameService = isDbMode + ? createTRPCGameService() + : createZustandGameService() + +export const settingsService = isDbMode + ? createTRPCSettingsService() + : createZustandSettingsService() + +export const profileService = isDbMode + ? createTRPCProfileService() + : createZustandProfileService() + +export const modService = isDbMode + ? createTRPCModService() + : createZustandModService() diff --git a/src/data/trpc-services.ts b/src/data/trpc-services.ts new file mode 100644 index 0000000..3d4fc4e --- /dev/null +++ b/src/data/trpc-services.ts @@ -0,0 +1,239 @@ +/** + * tRPC-backed implementations of the data service interfaces. + * + * Each method delegates to the vanilla tRPC client which communicates with + * the Electron main process over IPC → SQLite via Drizzle ORM. + * + * The client is lazily initialized on first use so the module can be safely + * imported even if `window.electronTRPC` is not yet available. + */ + +import type { + IGameService, + ISettingsService, + IProfileService, + IModService, + ManagedGame, + EffectiveGameSettings, +} from "./interfaces" +import { createVanillaTRPCClient } from "@/lib/trpc" +import type { AppRouter } from "../../electron/trpc/router" +import type { TRPCClient } from "@trpc/client" + +// --------------------------------------------------------------------------- +// Lazy client singleton +// --------------------------------------------------------------------------- + +let _client: TRPCClient | null = null + +function getClient(): TRPCClient { + if (!_client) { + _client = createVanillaTRPCClient() + if (!_client) { + throw new Error( + "VITE_DATASOURCE=db but electronTRPC is not available. " + + "DB mode requires running inside Electron.", + ) + } + } + return _client +} + +// --------------------------------------------------------------------------- +// Game service +// --------------------------------------------------------------------------- + +export function createTRPCGameService(): IGameService { + return { + async list(): Promise { + return getClient().data.games.list.query() + }, + + async getDefault(): Promise { + return getClient().data.games.getDefault.query() + }, + + async getRecent(limit = 10): Promise { + return getClient().data.games.getRecent.query({ limit }) + }, + + async add(gameId: string): Promise { + await getClient().data.games.add.mutate({ gameId }) + }, + + async remove(gameId: string): Promise { + return getClient().data.games.remove.mutate({ gameId }) + }, + + async setDefault(gameId: string | null): Promise { + await getClient().data.games.setDefault.mutate({ gameId }) + }, + + async touch(gameId: string): Promise { + await getClient().data.games.touch.mutate({ gameId }) + }, + } +} + +// --------------------------------------------------------------------------- +// Settings service +// --------------------------------------------------------------------------- + +export function createTRPCSettingsService(): ISettingsService { + return { + async getGlobal() { + return getClient().data.settings.getGlobal.query() + }, + + async getForGame(gameId: string) { + return getClient().data.settings.getForGame.query({ gameId }) + }, + + async getEffective(gameId: string): Promise { + return getClient().data.settings.getEffective.query({ + gameId, + }) as Promise + }, + + async updateGlobal(updates) { + await getClient().data.settings.updateGlobal.mutate({ updates }) + }, + + async updateForGame(gameId, updates) { + await getClient().data.settings.updateForGame.mutate({ gameId, updates }) + }, + + async resetForGame(gameId) { + await getClient().data.settings.resetForGame.mutate({ gameId }) + }, + + async deleteForGame(gameId) { + await getClient().data.settings.deleteForGame.mutate({ gameId }) + }, + } +} + +// --------------------------------------------------------------------------- +// Profile service +// --------------------------------------------------------------------------- + +export function createTRPCProfileService(): IProfileService { + return { + async list(gameId) { + return getClient().data.profiles.list.query({ gameId }) + }, + + async getActive(gameId) { + return getClient().data.profiles.getActive.query({ gameId }) + }, + + async ensureDefault(gameId) { + return getClient().data.profiles.ensureDefault.mutate({ gameId }) + }, + + async create(gameId, name) { + return getClient().data.profiles.create.mutate({ gameId, name }) + }, + + async rename(gameId, profileId, newName) { + await getClient().data.profiles.rename.mutate({ + gameId, + profileId, + newName, + }) + }, + + async remove(gameId, profileId) { + return getClient().data.profiles.remove.mutate({ gameId, profileId }) + }, + + async setActive(gameId, profileId) { + await getClient().data.profiles.setActive.mutate({ gameId, profileId }) + }, + + async reset(gameId) { + return getClient().data.profiles.reset.mutate({ gameId }) + }, + + async removeAll(gameId) { + await getClient().data.profiles.removeAll.mutate({ gameId }) + }, + } +} + +// --------------------------------------------------------------------------- +// Mod service +// --------------------------------------------------------------------------- + +export function createTRPCModService(): IModService { + return { + async listInstalled(profileId) { + return getClient().data.mods.listInstalled.query({ profileId }) + }, + + async isInstalled(profileId, modId) { + return getClient().data.mods.isInstalled.query({ profileId, modId }) + }, + + async isEnabled(profileId, modId) { + return getClient().data.mods.isEnabled.query({ profileId, modId }) + }, + + async getInstalledVersion(profileId, modId) { + return getClient().data.mods.getInstalledVersion.query({ + profileId, + modId, + }) + }, + + async getDependencyWarnings(profileId, modId) { + return getClient().data.mods.getDependencyWarnings.query({ + profileId, + modId, + }) + }, + + async install(profileId, modId, version) { + await getClient().data.mods.install.mutate({ profileId, modId, version }) + }, + + async uninstall(profileId, modId) { + await getClient().data.mods.uninstall.mutate({ profileId, modId }) + }, + + async uninstallAll(profileId) { + return getClient().data.mods.uninstallAll.mutate({ profileId }) + }, + + async enable(profileId, modId) { + await getClient().data.mods.enable.mutate({ profileId, modId }) + }, + + async disable(profileId, modId) { + await getClient().data.mods.disable.mutate({ profileId, modId }) + }, + + async toggle(profileId, modId) { + await getClient().data.mods.toggle.mutate({ profileId, modId }) + }, + + async setDependencyWarnings(profileId, modId, warnings) { + await getClient().data.mods.setDependencyWarnings.mutate({ + profileId, + modId, + warnings, + }) + }, + + async clearDependencyWarnings(profileId, modId) { + await getClient().data.mods.clearDependencyWarnings.mutate({ + profileId, + modId, + }) + }, + + async deleteProfileState(profileId) { + await getClient().data.mods.deleteProfileState.mutate({ profileId }) + }, + } +} diff --git a/src/lib/trpc.tsx b/src/lib/trpc.tsx index 0cc2e49..97d4084 100644 --- a/src/lib/trpc.tsx +++ b/src/lib/trpc.tsx @@ -1,5 +1,6 @@ import type { QueryClient } from "@tanstack/react-query" import { createTRPCReact } from "@trpc/react-query" +import { createTRPCClient as createVanillaClient } from "@trpc/client" import { ipcLink } from "electron-trpc-experimental/renderer" import superjson from "superjson" import type { AppRouter } from "../../electron/trpc/router" @@ -36,6 +37,24 @@ export function createTRPCClient() { }) } +/** + * Vanilla (non-React) tRPC client for imperative use in service singletons. + * Returns null if not running in Electron. + */ +export function createVanillaTRPCClient() { + if (!hasElectronTRPC()) { + return null + } + + return createVanillaClient({ + links: [ + ipcLink({ + transformer: superjson, + }), + ], + }) +} + /** * tRPC Provider component that wraps the app * Conditionally creates the client only in Electron mode diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index f849f6e..cbc532d 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -5,6 +5,7 @@ interface ImportMetaEnv { readonly APP_MODE: "UAT" | "production" readonly APP_BUILD_TIME: string readonly APP_BUILD_INFO: string + readonly VITE_DATASOURCE?: "db" | "zustand" } interface ImportMeta {