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/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/electron/trpc/data/games.ts b/electron/trpc/data/games.ts new file mode 100644 index 0000000..b35df51 --- /dev/null +++ b/electron/trpc/data/games.ts @@ -0,0 +1,95 @@ +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 { + return iso ? new Date(iso).getTime() : null +} + +export const dataGamesRouter = t.router({ + /** Get all managed games */ + list: publicProcedure.query(async () => { + 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 () => { + 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 }) => { + 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 }) => { + 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 }) => { + 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 }) => { + 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 }) => { + const db = getDb() + await db + .update(game) + .set({ lastAccessedAt: new Date().toISOString() }) + .where(eq(game.id, input.gameId)) + }), +}) 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..6cf5db1 --- /dev/null +++ b/electron/trpc/data/mods.ts @@ -0,0 +1,231 @@ +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 + installedVersion: string + enabled: boolean + 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 }) => { + 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 */ + isInstalled: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + })) + .query(async ({ input }) => { + 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 */ + isEnabled: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + })) + .query(async ({ input }) => { + 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 */ + getInstalledVersion: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + })) + .query(async ({ input }) => { + 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 */ + getDependencyWarnings: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + })) + .query(async ({ input }) => { + 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) */ + install: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + version: z.string().min(1), + })) + .mutation(async ({ 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 */ + uninstall: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + })) + .mutation(async ({ 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 }) => { + const db = getDb() + const deleted = await db + .delete(profileMod) + .where(eq(profileMod.profileId, input.profileId)) + .returning() + return deleted.length + }), + + /** Enable a mod */ + enable: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + })) + .mutation(async ({ 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 */ + disable: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + })) + .mutation(async ({ 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 */ + toggle: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + })) + .mutation(async ({ 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 */ + setDependencyWarnings: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + warnings: z.array(z.string()), + })) + .mutation(async ({ 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 */ + clearDependencyWarnings: publicProcedure + .input(z.object({ + profileId: z.string().min(1), + modId: z.string().min(1), + })) + .mutation(async ({ 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 }) => { + 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 new file mode 100644 index 0000000..583e101 --- /dev/null +++ b/electron/trpc/data/profiles.ts @@ -0,0 +1,266 @@ +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 + name: string + 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 }) => { + 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 }) => { + 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 }) => { + 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 */ + create: publicProcedure + .input(z.object({ + gameId: z.string().min(1), + name: z.string().min(1), + })) + .mutation(async ({ input }) => { + 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 */ + rename: publicProcedure + .input(z.object({ + gameId: z.string().min(1), + profileId: z.string().min(1), + newName: z.string().min(1), + })) + .mutation(async ({ 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? }. */ + remove: publicProcedure + .input(z.object({ + gameId: z.string().min(1), + profileId: z.string().min(1), + })) + .mutation(async ({ input }) => { + 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 */ + setActive: publicProcedure + .input(z.object({ + gameId: z.string().min(1), + profileId: z.string().min(1), + })) + .mutation(async ({ 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 }) => { + 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 }) => { + 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 new file mode 100644 index 0000000..309684c --- /dev/null +++ b/electron/trpc/data/settings.ts @@ -0,0 +1,266 @@ +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 +// --------------------------------------------------------------------------- + +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, +} + +/** 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 () => { + return ensureGlobalRow() + }), + + /** Get raw per-game settings */ + getForGame: publicProcedure + .input(z.object({ gameId: z.string().min(1) })) + .query(async ({ input }) => { + 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 }) => { + 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 }) => { + 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) */ + updateForGame: publicProcedure + .input(z.object({ + gameId: z.string().min(1), + updates: gameSettingsPartialSchema, + })) + .mutation(async ({ 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 }) => { + 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 }) => { + const db = getDb() + await db.delete(gameSettings).where(eq(gameSettings.gameId, input.gameId)) + }), +}) 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 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 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/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 new file mode 100644 index 0000000..0721d71 --- /dev/null +++ b/src/data/hooks.ts @@ -0,0 +1,658 @@ +/** + * React hooks – the stable API that components import. + * + * Data hooks: useSuspenseQuery → return type is always T (never undefined). + * Hook return shapes are identical regardless of VITE_DATASOURCE. + * Action hooks: call async service functions directly. + * 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) + * After: const { profilesByGame } = useProfileData() + */ + +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 "./services" +import type { GlobalSettings, GameSettings } from "./interfaces" +import { isDbMode, isZustandMode } from "./datasource" + +// --------------------------------------------------------------------------- +// Query keys +// --------------------------------------------------------------------------- + +export const dataKeys = { + gameManagement: ["store", "gameManagement"] as const, + settings: ["store", "settings"] as const, + profiles: ["store", "profiles"] as const, + modManagement: ["store", "modManagement"] as const, +} + +// --------------------------------------------------------------------------- +// DataBridge – subscribe to Zustand stores and invalidate React Query cache +// --------------------------------------------------------------------------- + +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 }) + }), + 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 +} + +// =========================================================================== +// Game Management +// =========================================================================== + +type GameManagementData = { + managedGameIds: string[] + recentManagedGameIds: string[] + defaultGameId: string | null +} + +export function useGameManagementData(): GameManagementData { + const { data } = useSuspenseQuery({ + queryKey: dataKeys.gameManagement, + queryFn: async (): Promise => { + 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, + } + } + const s = useGameManagementStore.getState() + return { + managedGameIds: s.managedGameIds, + recentManagedGameIds: s.recentManagedGameIds, + 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: 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], + ) +} + +// =========================================================================== +// Settings +// =========================================================================== + +type SettingsData = { + global: GlobalSettings + perGame: Record + /** Derived helper matching store's getPerGame(). */ + getPerGame: (gameId: string) => GameSettings +} + +const defaultGameSettings: GameSettings = { + gameInstallFolder: "", + modDownloadFolder: "", + cacheFolder: "", + modCacheFolder: "", + launchParameters: "", + onlineModListCacheDate: null, +} + +export function useSettingsData(): SettingsData { + const { data } = useSuspenseQuery({ + queryKey: dataKeys.settings, + queryFn: async () => { + 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 + >, + } + } + 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, + }) + + const getPerGame = useCallback( + (gameId: string): GameSettings => ({ + ...defaultGameSettings, + ...data.perGame[gameId], + }), + [data.perGame], + ) + + return { global: data.global, perGame: data.perGame, getPerGame } +} + +export function useSettingsActions() { + const queryClient = useQueryClient() + + return useMemo( + () => ({ + 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], + ) +} + +// =========================================================================== +// Profiles +// =========================================================================== + +type ProfileData = { + profilesByGame: Record + activeProfileIdByGame: Record +} + +export function useProfileData(): ProfileData { + const { data } = useSuspenseQuery({ + queryKey: dataKeys.profiles, + queryFn: async (): Promise => { + 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]), + ), + } + } + 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: 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, + ) => { + 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], + ) +} + +// =========================================================================== +// Mod Management +// =========================================================================== + +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 + 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 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 (): 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, + } + } + const s = useModManagementStore.getState() + return { + installedModsByProfile: s.installedModsByProfile, + enabledModsByProfile: s.enabledModsByProfile, + installedModVersionsByProfile: s.installedModVersionsByProfile, + dependencyWarningsByProfile: s.dependencyWarningsByProfile, + } + }, + ...(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 + }) + + // 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], + ) + + const isModEnabled = useCallback( + (profileId: string, modId: string) => { + const set = data.enabledModsByProfile[profileId] + return set ? set.has(modId) : false + }, + [data.enabledModsByProfile], + ) + + 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], + ) + + return { + ...data, + uninstallingMods, + isModInstalled, + isModEnabled, + getInstalledModIds, + getInstalledVersion, + getDependencyWarnings, + } +} + +export function useModManagementActions() { + const queryClient = useQueryClient() + + return useMemo( + () => ({ + 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[], + ) => { + 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], + ) +} + +// =========================================================================== +// Convenience: combined mutation for "unmanage game" flow +// =========================================================================== + +export function useUnmanageGame() { + const gameMut = useGameManagementActions() + const settingsMut = useSettingsActions() + const profileMut = useProfileActions() + const modMut = useModManagementActions() + + return useCallback( + async (gameId: string) => { + // 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], + ) +} diff --git a/src/data/index.ts b/src/data/index.ts new file mode 100644 index 0000000..64fe7d1 --- /dev/null +++ b/src/data/index.ts @@ -0,0 +1,49 @@ +/** + * Data layer entry point. + * + * Swap the service implementations in services.ts when migrating from + * Zustand to DB/tRPC. + */ + +// 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 { + ManagedGame, + Profile, + InstalledMod, + GlobalSettings, + GameSettings, + EffectiveGameSettings, + IGameService, + ISettingsService, + IProfileService, + IModService, +} from "./interfaces" + +// Re-export hooks + DataBridge +export { + // Bridge (mount once near app root) + DataBridge, + dataKeys, + // Game Management + useGameManagementData, + useGameManagementActions, + // Settings + 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 new file mode 100644 index 0000000..838c882 --- /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 = { + gameInstallFolder: 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/services.ts b/src/data/services.ts new file mode 100644 index 0000000..433b298 --- /dev/null +++ b/src/data/services.ts @@ -0,0 +1,39 @@ +/** + * Service singletons – conditionally backed by Zustand or tRPC/DB based on + * the VITE_DATASOURCE build flag. + * + * 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, + createZustandProfileService, + createZustandModService, +} from "./zustand" + +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/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) + }, + } +} 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/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/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 && ( 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 {